Compare commits
43 Commits
0b78950d14
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
a10f3ef0f5
|
|||
|
e3a731e3fa
|
|||
|
8e66d57994
|
|||
|
87c651eed9
|
|||
|
e8ab85a189
|
|||
|
1fcf331729
|
|||
|
3f0a669b2b
|
|||
|
07fff2cebd
|
|||
|
4b81d3bd1e
|
|||
|
338ccc64df
|
|||
|
729dc0ac55
|
|||
|
86f08a4bb2
|
|||
|
08e67ce308
|
|||
|
dc9f51c04f
|
|||
|
980d99e40b
|
|||
|
06b663052e
|
|||
|
007d13be2f
|
|||
|
88af6f722e
|
|||
|
5a7cf4714b
|
|||
|
e4ddec0d53
|
|||
|
bb88d48eb3
|
|||
|
d957cc1c19
|
|||
|
2aaa408712
|
|||
|
291bed28d5
|
|||
|
1a38922ac0
|
|||
|
76bd788769
|
|||
|
0134c9bfc2
|
|||
|
0775474f57
|
|||
|
f2c79ad56f
|
|||
|
728a6c3000
|
|||
|
57766c990e
|
|||
|
b2b5e32535
|
|||
|
881737978d
|
|||
|
6a764ade2b
|
|||
|
5f03056534
|
|||
|
10dd0dac82
|
|||
|
3cc7fe9926
|
|||
|
bad4a49f41
|
|||
|
9276f88426
|
|||
|
a3fb87f86e
|
|||
|
b359a3317f
|
|||
|
e0ec15b91e
|
|||
|
44a0964181
|
40
.devcontainer/devcontainer.json
Normal file
40
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "swift-manual-d-dev",
|
||||
"image": "git.housh.dev/michael/swift-dev-container:latest",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/git:1": {
|
||||
"version": "os-provided",
|
||||
"ppa": "false"
|
||||
},
|
||||
"ghcr.io/jsburckhardt/devcontainer-features/just:1": {},
|
||||
"ghcr.io/rocker-org/devcontainer-features/pandoc:1": {},
|
||||
//"ghcr.io/devcontainers/features/docker-in-docker:2": {},
|
||||
"ghcr.io/wxw-matt/devcontainer-features/apt:latest": {
|
||||
"packages": "weasyprint gnupg2 tmux"
|
||||
},
|
||||
"ghcr.io/swift-server-community/swift-devcontainer-features/jemalloc:1": { },
|
||||
|
||||
},
|
||||
"runArgs": [
|
||||
"--cap-add=SYS_PTRACE",
|
||||
"--security-opt",
|
||||
"seccomp=unconfined"
|
||||
],
|
||||
"remoteEnv": {
|
||||
// Set TERM, prevents problems when "xterm-ghostty" get's passed from host,
|
||||
// which causes weird typing and display problems.
|
||||
"TERM": "xterm-256color",
|
||||
// Less backtrace error warnings from swift.
|
||||
"SWIFT_BACKTRACE": "enable=no"
|
||||
},
|
||||
"remoteUser": "swift",
|
||||
"forwardPorts": [8080],
|
||||
"mounts": [
|
||||
{
|
||||
"type": "bind",
|
||||
"source": "${localEnv:HOME}/.local/share/nvim",
|
||||
"target": "/home/swift/.local/share/nvim"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
name: Create and publish a Docker image
|
||||
|
||||
# Configures this workflow to run every time a change is pushed to the branch called `release`.
|
||||
on:
|
||||
push:
|
||||
# branches: ['main']
|
||||
@@ -8,17 +7,14 @@ on:
|
||||
- '*.*.*'
|
||||
workflow_dispatch:
|
||||
|
||||
# Defines two custom environment variables for the workflow. These are used for the Container registry domain,
|
||||
# and a name for the Docker image that this workflow builds.
|
||||
env:
|
||||
REGISTRY: git.housh.dev
|
||||
USERNAME: michael
|
||||
IMAGE_NAME: ${{ gitea.repository }}
|
||||
|
||||
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -27,39 +23,39 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
# Uses the `docker/login-action` action to log in to the Container registry registry using the account
|
||||
# and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.CONTAINER_TOKEN }}
|
||||
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels
|
||||
# that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a
|
||||
# subsequent step. The `images` value provides the base name for the tags and labels.
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major.minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
type=raw,value=prod
|
||||
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If
|
||||
# the build succeeds, it pushes the image to GitHub Packages. It uses the `context` parameter to define the build's context
|
||||
# as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)"
|
||||
# in the README of the `docker/build-push-action` repository.
|
||||
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:build
|
||||
cache-to: mode=min,image-manifest=true,oci-mediatypes=true,type=inline,ref=${{ env.IMAGE_NAME }}:build
|
||||
|
||||
|
||||
31
.github/workflows/ci.yaml
vendored
Normal file
31
.github/workflows/ci.yaml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
ubuntu:
|
||||
name: Linux Tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Setup buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build test image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile.test
|
||||
push: false
|
||||
load: true
|
||||
tags: ${{ github.repository_owner }}/ductcalc:test
|
||||
- name: Run Tests
|
||||
run: |
|
||||
docker run --rm ${{ github.repository_owner }}/ductcalc:test swift test
|
||||
73
.github/workflows/release.yaml
vendored
Normal file
73
.github/workflows/release.yaml
vendored
Normal file
@@ -0,0 +1,73 @@
|
||||
name: Create and publish a Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
# branches: ['main']
|
||||
tags:
|
||||
- '*.*.*'
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
USERNAME: m-housh
|
||||
|
||||
jobs:
|
||||
build-and-push-image:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.USERNAME }}
|
||||
password: ${{ secrets.CONTAINER_TOKEN }}
|
||||
|
||||
- name: Set up Docker
|
||||
uses: docker/setup-docker-action@v4
|
||||
with:
|
||||
daemon-config: |
|
||||
{
|
||||
"debug": true,
|
||||
"features": {
|
||||
"containerd-snapshotter": true
|
||||
}
|
||||
}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.USERNAME }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major.minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=sha
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=${{ env.USERNAME }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: mode=max,image-manifest=true,oci-mediatypes=true,type=registry,ref=${{ env.USERNAME }}/${{ env.IMAGE_NAME }}:build
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,3 +14,4 @@ tailwindcss
|
||||
.env
|
||||
.env*
|
||||
default.profraw
|
||||
/rooms.csv
|
||||
|
||||
394
LICENSE
394
LICENSE
@@ -1,18 +1,384 @@
|
||||
MIT License
|
||||
Copyright (c) 2026 Michael Housh II
|
||||
|
||||
Copyright (c) 2025 michael
|
||||
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International
|
||||
Public License
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
By exercising the Licensed Rights (defined below), You accept and agree
|
||||
to be bound by the terms and conditions of this Creative Commons
|
||||
Attribution-NonCommercial-ShareAlike 4.0 International Public License
|
||||
("Public License"). To the extent this Public License may be
|
||||
interpreted as a contract, You are granted the Licensed Rights in
|
||||
consideration of Your acceptance of these terms and conditions, and the
|
||||
Licensor grants You such rights in consideration of benefits the
|
||||
Licensor receives from making the Licensed Material available under
|
||||
these terms and conditions.
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
Section 1 -- Definitions.
|
||||
|
||||
a. Adapted Material means material subject to Copyright and Similar
|
||||
Rights that is derived from or based upon the Licensed Material
|
||||
and in which the Licensed Material is translated, altered,
|
||||
arranged, transformed, or otherwise modified in a manner requiring
|
||||
permission under the Copyright and Similar Rights held by the
|
||||
Licensor. For purposes of this Public License, where the Licensed
|
||||
Material is a musical work, performance, or sound recording,
|
||||
Adapted Material is always produced where the Licensed Material is
|
||||
synched in timed relation with a moving image.
|
||||
|
||||
b. Adapter's License means the license You apply to Your Copyright
|
||||
and Similar Rights in Your contributions to Adapted Material in
|
||||
accordance with the terms and conditions of this Public License.
|
||||
|
||||
c. BY-NC-SA Compatible License means a license listed at
|
||||
creativecommons.org/compatiblelicenses, approved by Creative
|
||||
Commons as essentially the equivalent of this Public License.
|
||||
|
||||
d. Copyright and Similar Rights means copyright and/or similar rights
|
||||
closely related to copyright including, without limitation,
|
||||
performance, broadcast, sound recording, and Sui Generis Database
|
||||
Rights, without regard to how the rights are labeled or
|
||||
categorized. For purposes of this Public License, the rights
|
||||
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
||||
Rights.
|
||||
|
||||
e. Effective Technological Measures means those measures that, in the
|
||||
absence of proper authority, may not be circumvented under laws
|
||||
fulfilling obligations under Article 11 of the WIPO Copyright
|
||||
Treaty adopted on December 20, 1996, and/or similar international
|
||||
agreements.
|
||||
|
||||
f. Exceptions and Limitations means fair use, fair dealing, and/or
|
||||
any other exception or limitation to Copyright and Similar Rights
|
||||
that applies to Your use of the Licensed Material.
|
||||
|
||||
g. License Elements means the license attributes listed in the name
|
||||
of a Creative Commons Public License. The License Elements of this
|
||||
Public License are Attribution, NonCommercial, and ShareAlike.
|
||||
|
||||
h. Licensed Material means the artistic or literary work, database,
|
||||
or other material to which the Licensor applied this Public
|
||||
License.
|
||||
|
||||
i. Licensed Rights means the rights granted to You subject to the
|
||||
terms and conditions of this Public License, which are limited to
|
||||
all Copyright and Similar Rights that apply to Your use of the
|
||||
Licensed Material and that the Licensor has authority to license.
|
||||
|
||||
j. Licensor means the individual(s) or entity(ies) granting rights
|
||||
under this Public License.
|
||||
|
||||
k. NonCommercial means not primarily intended for or directed towards
|
||||
commercial advantage or monetary compensation. For purposes of
|
||||
this Public License, the exchange of the Licensed Material for
|
||||
other material subject to Copyright and Similar Rights by digital
|
||||
file-sharing or similar means is NonCommercial provided there is
|
||||
no payment of monetary compensation in connection with the
|
||||
exchange.
|
||||
|
||||
l. Share means to provide material to the public by any means or
|
||||
process that requires permission under the Licensed Rights, such
|
||||
as reproduction, public display, public performance, distribution,
|
||||
dissemination, communication, or importation, and to make material
|
||||
available to the public including in ways that members of the
|
||||
public may access the material from a place and at a time
|
||||
individually chosen by them.
|
||||
|
||||
m. Sui Generis Database Rights means rights other than copyright
|
||||
resulting from Directive 96/9/EC of the European Parliament and of
|
||||
the Council of 11 March 1996 on the legal protection of databases,
|
||||
as amended and/or succeeded, as well as other essentially
|
||||
equivalent rights anywhere in the world.
|
||||
|
||||
n. You means the individual or entity exercising the Licensed Rights
|
||||
under this Public License. Your has a corresponding meaning.
|
||||
|
||||
|
||||
Section 2 -- Scope.
|
||||
|
||||
a. License grant.
|
||||
|
||||
1. Subject to the terms and conditions of this Public License,
|
||||
the Licensor hereby grants You a worldwide, royalty-free,
|
||||
non-sublicensable, non-exclusive, irrevocable license to
|
||||
exercise the Licensed Rights in the Licensed Material to:
|
||||
|
||||
a. reproduce and Share the Licensed Material, in whole or
|
||||
in part, for NonCommercial purposes only; and
|
||||
|
||||
b. produce, reproduce, and Share Adapted Material for
|
||||
NonCommercial purposes only.
|
||||
|
||||
2. Exceptions and Limitations. For the avoidance of doubt, where
|
||||
Exceptions and Limitations apply to Your use, this Public
|
||||
License does not apply, and You do not need to comply with
|
||||
its terms and conditions.
|
||||
|
||||
3. Term. The term of this Public License is specified in Section
|
||||
6(a).
|
||||
|
||||
4. Media and formats; technical modifications allowed. The
|
||||
Licensor authorizes You to exercise the Licensed Rights in
|
||||
all media and formats whether now known or hereafter created,
|
||||
and to make technical modifications necessary to do so. The
|
||||
Licensor waives and/or agrees not to assert any right or
|
||||
authority to forbid You from making technical modifications
|
||||
necessary to exercise the Licensed Rights, including
|
||||
technical modifications necessary to circumvent Effective
|
||||
Technological Measures. For purposes of this Public License,
|
||||
simply making modifications authorized by this Section 2(a)
|
||||
(4) never produces Adapted Material.
|
||||
|
||||
5. Downstream recipients.
|
||||
|
||||
a. Offer from the Licensor -- Licensed Material. Every
|
||||
recipient of the Licensed Material automatically
|
||||
receives an offer from the Licensor to exercise the
|
||||
Licensed Rights under the terms and conditions of this
|
||||
Public License.
|
||||
|
||||
b. Additional offer from the Licensor -- Adapted Material.
|
||||
Every recipient of Adapted Material from You
|
||||
automatically receives an offer from the Licensor to
|
||||
exercise the Licensed Rights in the Adapted Material
|
||||
under the conditions of the Adapter's License You apply.
|
||||
|
||||
c. No downstream restrictions. You may not offer or impose
|
||||
any additional or different terms or conditions on, or
|
||||
apply any Effective Technological Measures to, the
|
||||
Licensed Material if doing so restricts exercise of the
|
||||
Licensed Rights by any recipient of the Licensed
|
||||
Material.
|
||||
|
||||
6. No endorsement. Nothing in this Public License constitutes or
|
||||
may be construed as permission to assert or imply that You
|
||||
are, or that Your use of the Licensed Material is, connected
|
||||
with, or sponsored, endorsed, or granted official status by,
|
||||
the Licensor or others designated to receive attribution as
|
||||
provided in Section 3(a)(1)(A)(i).
|
||||
|
||||
b. Other rights.
|
||||
|
||||
1. Moral rights, such as the right of integrity, are not
|
||||
licensed under this Public License, nor are publicity,
|
||||
privacy, and/or other similar personality rights; however, to
|
||||
the extent possible, the Licensor waives and/or agrees not to
|
||||
assert any such rights held by the Licensor to the limited
|
||||
extent necessary to allow You to exercise the Licensed
|
||||
Rights, but not otherwise.
|
||||
|
||||
2. Patent and trademark rights are not licensed under this
|
||||
Public License.
|
||||
|
||||
3. To the extent possible, the Licensor waives any right to
|
||||
collect royalties from You for the exercise of the Licensed
|
||||
Rights, whether directly or through a collecting society
|
||||
under any voluntary or waivable statutory or compulsory
|
||||
licensing scheme. In all other cases the Licensor expressly
|
||||
reserves any right to collect such royalties, including when
|
||||
the Licensed Material is used other than for NonCommercial
|
||||
purposes.
|
||||
|
||||
|
||||
Section 3 -- License Conditions.
|
||||
|
||||
Your exercise of the Licensed Rights is expressly made subject to the
|
||||
following conditions.
|
||||
|
||||
a. Attribution.
|
||||
|
||||
1. If You Share the Licensed Material (including in modified
|
||||
form), You must:
|
||||
|
||||
a. retain the following if it is supplied by the Licensor
|
||||
with the Licensed Material:
|
||||
|
||||
i. identification of the creator(s) of the Licensed
|
||||
Material and any others designated to receive
|
||||
attribution, in any reasonable manner requested by
|
||||
the Licensor (including by pseudonym if
|
||||
designated);
|
||||
|
||||
ii. a copyright notice;
|
||||
|
||||
iii. a notice that refers to this Public License;
|
||||
|
||||
iv. a notice that refers to the disclaimer of
|
||||
warranties;
|
||||
|
||||
v. a URI or hyperlink to the Licensed Material to the
|
||||
extent reasonably practicable;
|
||||
|
||||
b. indicate if You modified the Licensed Material and
|
||||
retain an indication of any previous modifications; and
|
||||
|
||||
c. indicate the Licensed Material is licensed under this
|
||||
Public License, and include the text of, or the URI or
|
||||
hyperlink to, this Public License.
|
||||
|
||||
2. You may satisfy the conditions in Section 3(a)(1) in any
|
||||
reasonable manner based on the medium, means, and context in
|
||||
which You Share the Licensed Material. For example, it may be
|
||||
reasonable to satisfy the conditions by providing a URI or
|
||||
hyperlink to a resource that includes the required
|
||||
information.
|
||||
3. If requested by the Licensor, You must remove any of the
|
||||
information required by Section 3(a)(1)(A) to the extent
|
||||
reasonably practicable.
|
||||
|
||||
b. ShareAlike.
|
||||
|
||||
In addition to the conditions in Section 3(a), if You Share
|
||||
Adapted Material You produce, the following conditions also apply.
|
||||
|
||||
1. The Adapter's License You apply must be a Creative Commons
|
||||
license with the same License Elements, this version or
|
||||
later, or a BY-NC-SA Compatible License.
|
||||
|
||||
2. You must include the text of, or the URI or hyperlink to, the
|
||||
Adapter's License You apply. You may satisfy this condition
|
||||
in any reasonable manner based on the medium, means, and
|
||||
context in which You Share Adapted Material.
|
||||
|
||||
3. You may not offer or impose any additional or different terms
|
||||
or conditions on, or apply any Effective Technological
|
||||
Measures to, Adapted Material that restrict exercise of the
|
||||
rights granted under the Adapter's License You apply.
|
||||
|
||||
|
||||
Section 4 -- Sui Generis Database Rights.
|
||||
|
||||
Where the Licensed Rights include Sui Generis Database Rights that
|
||||
apply to Your use of the Licensed Material:
|
||||
|
||||
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
||||
to extract, reuse, reproduce, and Share all or a substantial
|
||||
portion of the contents of the database for NonCommercial purposes
|
||||
only;
|
||||
|
||||
b. if You include all or a substantial portion of the database
|
||||
contents in a database in which You have Sui Generis Database
|
||||
Rights, then the database in which You have Sui Generis Database
|
||||
Rights (but not its individual contents) is Adapted Material,
|
||||
including for purposes of Section 3(b); and
|
||||
|
||||
c. You must comply with the conditions in Section 3(a) if You Share
|
||||
all or a substantial portion of the contents of the database.
|
||||
|
||||
For the avoidance of doubt, this Section 4 supplements and does not
|
||||
replace Your obligations under this Public License where the Licensed
|
||||
Rights include other Copyright and Similar Rights.
|
||||
|
||||
|
||||
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
||||
|
||||
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
||||
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
||||
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
||||
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
||||
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
||||
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
||||
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
||||
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
||||
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
||||
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
||||
|
||||
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
||||
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
||||
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
||||
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
||||
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
||||
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
||||
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
||||
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
||||
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
||||
|
||||
c. The disclaimer of warranties and limitation of liability provided
|
||||
above shall be interpreted in a manner that, to the extent
|
||||
possible, most closely approximates an absolute disclaimer and
|
||||
waiver of all liability.
|
||||
|
||||
|
||||
Section 6 -- Term and Termination.
|
||||
|
||||
a. This Public License applies for the term of the Copyright and
|
||||
Similar Rights licensed here. However, if You fail to comply with
|
||||
this Public License, then Your rights under this Public License
|
||||
terminate automatically.
|
||||
|
||||
b. Where Your right to use the Licensed Material has terminated under
|
||||
Section 6(a), it reinstates:
|
||||
|
||||
1. automatically as of the date the violation is cured, provided
|
||||
it is cured within 30 days of Your discovery of the
|
||||
violation; or
|
||||
|
||||
2. upon express reinstatement by the Licensor.
|
||||
|
||||
For the avoidance of doubt, this Section 6(b) does not affect any
|
||||
right the Licensor may have to seek remedies for Your violations
|
||||
of this Public License.
|
||||
|
||||
c. For the avoidance of doubt, the Licensor may also offer the
|
||||
Licensed Material under separate terms or conditions or stop
|
||||
distributing the Licensed Material at any time; however, doing so
|
||||
will not terminate this Public License.
|
||||
|
||||
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
||||
License.
|
||||
|
||||
|
||||
Section 7 -- Other Terms and Conditions.
|
||||
|
||||
a. The Licensor shall not be bound by any additional or different
|
||||
terms or conditions communicated by You unless expressly agreed.
|
||||
|
||||
b. Any arrangements, understandings, or agreements regarding the
|
||||
Licensed Material not stated herein are separate from and
|
||||
independent of the terms and conditions of this Public License.
|
||||
|
||||
|
||||
Section 8 -- Interpretation.
|
||||
|
||||
a. For the avoidance of doubt, this Public License does not, and
|
||||
shall not be interpreted to, reduce, limit, restrict, or impose
|
||||
conditions on any use of the Licensed Material that could lawfully
|
||||
be made without permission under this Public License.
|
||||
|
||||
b. To the extent possible, if any provision of this Public License is
|
||||
deemed unenforceable, it shall be automatically reformed to the
|
||||
minimum extent necessary to make it enforceable. If the provision
|
||||
cannot be reformed, it shall be severed from this Public License
|
||||
without affecting the enforceability of the remaining terms and
|
||||
conditions.
|
||||
|
||||
c. No term or condition of this Public License will be waived and no
|
||||
failure to comply consented to unless expressly agreed to by the
|
||||
Licensor.
|
||||
|
||||
d. Nothing in this Public License constitutes or may be interpreted
|
||||
as a limitation upon, or waiver of, any privileges and immunities
|
||||
that apply to the Licensor or You, including from the legal
|
||||
processes of any jurisdiction or authority.
|
||||
|
||||
=======================================================================
|
||||
|
||||
Creative Commons is not a party to its public
|
||||
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
||||
its public licenses to material it publishes and in those instances
|
||||
will be considered the “Licensor.” The text of the Creative Commons
|
||||
public licenses is dedicated to the public domain under the CC0 Public
|
||||
Domain Dedication. Except for the limited purpose of indicating that
|
||||
material is shared under a Creative Commons public license or as
|
||||
otherwise permitted by the Creative Commons policies published at
|
||||
creativecommons.org/policies, Creative Commons does not authorize the
|
||||
use of the trademark "Creative Commons" or any other trademark or logo
|
||||
of Creative Commons without its prior written consent including,
|
||||
without limitation, in connection with any unauthorized modifications
|
||||
to any of its public licenses or any other arrangements,
|
||||
understandings, or agreements concerning use of licensed material. For
|
||||
the avoidance of doubt, this paragraph does not form part of the
|
||||
public licenses.
|
||||
|
||||
Creative Commons may be contacted at creativecommons.org.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"originHash" : "300df15f5af26da69cfcf959d16ee1b9eada6101dc105a17fc01ddd244d476b4",
|
||||
"originHash" : "5bbd172c602e6484b32782f8cb68faca2d5120adc511acdff74e62fec2178c15",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "async-http-client",
|
||||
@@ -73,6 +73,15 @@
|
||||
"version" : "1.53.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "fluent-postgres-driver",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/fluent-postgres-driver.git",
|
||||
"state" : {
|
||||
"revision" : "59bff45a41d1ece1950bb8a6e0006d88c1fb6e69",
|
||||
"version" : "2.12.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "fluent-sqlite-driver",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -100,6 +109,24 @@
|
||||
"version" : "0.14.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "postgres-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/postgres-kit.git",
|
||||
"state" : {
|
||||
"revision" : "7c079553e9cda74811e627775bf22e40a9405ad9",
|
||||
"version" : "2.15.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "postgres-nio",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/vapor/postgres-nio.git",
|
||||
"state" : {
|
||||
"revision" : "d578b86fb2c8321b114d97cd70831d1a3e9531a6",
|
||||
"version" : "1.30.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "routing-kit",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -145,6 +172,15 @@
|
||||
"version" : "1.2.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-argument-parser",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/apple/swift-argument-parser.git",
|
||||
"state" : {
|
||||
"revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615",
|
||||
"version" : "1.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-asn1",
|
||||
"kind" : "remoteSourceControl",
|
||||
@@ -415,6 +451,15 @@
|
||||
"version" : "0.6.2"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-validations",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/m-housh/swift-validations.git",
|
||||
"state" : {
|
||||
"revision" : "ae939c146f380ca12d0a04ca1f6b0c4c270fdd5a",
|
||||
"version" : "0.3.5"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "vapor",
|
||||
"kind" : "remoteSourceControl",
|
||||
|
||||
@@ -6,10 +6,11 @@ let package = Package(
|
||||
name: "swift-manual-d",
|
||||
products: [
|
||||
.executable(name: "App", targets: ["App"]),
|
||||
.library(name: "ApiController", targets: ["ApiController"]),
|
||||
.executable(name: "ductcalc", targets: ["CLI"]),
|
||||
.library(name: "AuthClient", targets: ["AuthClient"]),
|
||||
.library(name: "CSVParser", targets: ["CSVParser"]),
|
||||
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
|
||||
.library(name: "EnvClient", targets: ["EnvClient"]),
|
||||
.library(name: "EnvVars", targets: ["EnvVars"]),
|
||||
.library(name: "FileClient", targets: ["FileClient"]),
|
||||
.library(name: "HTMLSnapshotTesting", targets: ["HTMLSnapshotTesting"]),
|
||||
.library(name: "PdfClient", targets: ["PdfClient"]),
|
||||
@@ -20,9 +21,11 @@ let package = Package(
|
||||
.library(name: "ViewController", targets: ["ViewController"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"),
|
||||
.package(url: "https://github.com/vapor/vapor.git", from: "4.110.1"),
|
||||
.package(url: "https://github.com/vapor/fluent.git", from: "4.9.0"),
|
||||
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.6.0"),
|
||||
.package(url: "https://github.com/vapor/fluent-postgres-driver.git", from: "2.0.0"),
|
||||
.package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
|
||||
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.12.0"),
|
||||
@@ -33,18 +36,19 @@ let package = Package(
|
||||
.package(url: "https://github.com/elementary-swift/elementary.git", from: "0.6.0"),
|
||||
.package(url: "https://github.com/elementary-swift/elementary-htmx.git", from: "0.5.0"),
|
||||
.package(url: "https://github.com/vapor-community/vapor-elementary.git", from: "0.1.0"),
|
||||
.package(url: "https://github.com/m-housh/swift-validations.git", from: "0.3.5"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "App",
|
||||
dependencies: [
|
||||
.target(name: "ApiController"),
|
||||
.target(name: "AuthClient"),
|
||||
.target(name: "DatabaseClient"),
|
||||
.target(name: "ViewController"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "Fluent", package: "fluent"),
|
||||
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
|
||||
.product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"),
|
||||
.product(name: "Vapor", package: "vapor"),
|
||||
.product(name: "NIOCore", package: "swift-nio"),
|
||||
.product(name: "NIOPosix", package: "swift-nio"),
|
||||
@@ -52,20 +56,11 @@ let package = Package(
|
||||
.product(name: "VaporRouting", package: "vapor-routing"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "ApiController",
|
||||
.executableTarget(
|
||||
name: "CLI",
|
||||
dependencies: [
|
||||
.target(name: "DatabaseClient"),
|
||||
.target(name: "ManualDCore"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "Vapor", package: "vapor"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "ApiRouteTests",
|
||||
dependencies: [
|
||||
.target(name: "ManualDCore")
|
||||
.target(name: "ManualDClient"),
|
||||
.product(name: "ArgumentParser", package: "swift-argument-parser"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
@@ -77,6 +72,20 @@ let package = Package(
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "CSVParser",
|
||||
dependencies: [
|
||||
.target(name: "ManualDCore"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "CSVParsingTests",
|
||||
dependencies: [
|
||||
.target(name: "CSVParser")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "DatabaseClient",
|
||||
dependencies: [
|
||||
@@ -85,6 +94,7 @@ let package = Package(
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
.product(name: "Fluent", package: "fluent"),
|
||||
.product(name: "Vapor", package: "vapor"),
|
||||
.product(name: "Validations", package: "swift-validations"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
@@ -94,10 +104,13 @@ let package = Package(
|
||||
.target(name: "DatabaseClient"),
|
||||
.product(name: "DependenciesTestSupport", package: "swift-dependencies"),
|
||||
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
|
||||
],
|
||||
resources: [
|
||||
.copy("Resources")
|
||||
]
|
||||
),
|
||||
.target(
|
||||
name: "EnvClient",
|
||||
name: "EnvVars",
|
||||
dependencies: [
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "DependenciesMacros", package: "swift-dependencies"),
|
||||
@@ -121,7 +134,7 @@ let package = Package(
|
||||
.target(
|
||||
name: "PdfClient",
|
||||
dependencies: [
|
||||
.target(name: "EnvClient"),
|
||||
.target(name: "EnvVars"),
|
||||
.target(name: "FileClient"),
|
||||
.target(name: "ManualDCore"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
@@ -155,6 +168,7 @@ let package = Package(
|
||||
.product(name: "CasePaths", package: "swift-case-paths"),
|
||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||
.product(name: "Fluent", package: "fluent"),
|
||||
.product(name: "Tagged", package: "swift-tagged"),
|
||||
.product(name: "URLRouting", package: "swift-url-routing"),
|
||||
]
|
||||
),
|
||||
@@ -186,6 +200,7 @@ let package = Package(
|
||||
name: "ViewController",
|
||||
dependencies: [
|
||||
.target(name: "AuthClient"),
|
||||
.target(name: "CSVParser"),
|
||||
.target(name: "DatabaseClient"),
|
||||
.target(name: "PdfClient"),
|
||||
.target(name: "ProjectClient"),
|
||||
|
||||
@@ -7,19 +7,18 @@
|
||||
'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
--color-red-500: oklch(63.7% 0.237 25.331);
|
||||
--color-red-600: oklch(57.7% 0.245 27.325);
|
||||
--color-green-400: oklch(79.2% 0.209 151.711);
|
||||
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
||||
--color-slate-300: oklch(86.9% 0.022 252.894);
|
||||
--color-slate-900: oklch(20.8% 0.042 265.755);
|
||||
--color-gray-200: oklch(92.8% 0.006 264.531);
|
||||
--color-gray-400: oklch(70.7% 0.022 261.325);
|
||||
--color-black: #000;
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
--text-sm: 0.875rem;
|
||||
--text-sm--line-height: calc(1.25 / 0.875);
|
||||
--text-base: 1rem;
|
||||
--text-base--line-height: calc(1.5 / 1);
|
||||
--text-lg: 1.125rem;
|
||||
--text-lg--line-height: calc(1.75 / 1.125);
|
||||
--text-xl: 1.25rem;
|
||||
@@ -28,10 +27,17 @@
|
||||
--text-2xl--line-height: calc(2 / 1.5);
|
||||
--text-3xl: 1.875rem;
|
||||
--text-3xl--line-height: calc(2.25 / 1.875);
|
||||
--text-4xl: 2.25rem;
|
||||
--text-4xl--line-height: calc(2.5 / 2.25);
|
||||
--text-5xl: 3rem;
|
||||
--text-5xl--line-height: 1;
|
||||
--text-8xl: 6rem;
|
||||
--text-8xl--line-height: 1;
|
||||
--font-weight-bold: 700;
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--radius-3xl: 1.5rem;
|
||||
--ease-out: cubic-bezier(0, 0, 0.2, 1);
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-transition-duration: 150ms;
|
||||
@@ -1690,6 +1696,9 @@
|
||||
opacity: 40%;
|
||||
}
|
||||
}
|
||||
.pointer-events-none {
|
||||
pointer-events: none;
|
||||
}
|
||||
.react-day-picker {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
user-select: none;
|
||||
@@ -4224,6 +4233,9 @@
|
||||
.top-2 {
|
||||
top: calc(var(--spacing) * 2);
|
||||
}
|
||||
.top-10 {
|
||||
top: calc(var(--spacing) * 10);
|
||||
}
|
||||
.right-2 {
|
||||
right: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -4287,6 +4299,9 @@
|
||||
.bottom-0 {
|
||||
bottom: calc(var(--spacing) * 0);
|
||||
}
|
||||
.-left-15 {
|
||||
left: calc(var(--spacing) * -15);
|
||||
}
|
||||
.left-0 {
|
||||
left: calc(var(--spacing) * 0);
|
||||
}
|
||||
@@ -4679,6 +4694,9 @@
|
||||
.z-1 {
|
||||
z-index: 1;
|
||||
}
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
.tab-content {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
order: var(--tabcontent-order);
|
||||
@@ -5231,6 +5249,9 @@
|
||||
.m-1 {
|
||||
margin: calc(var(--spacing) * 1);
|
||||
}
|
||||
.m-4 {
|
||||
margin: calc(var(--spacing) * 4);
|
||||
}
|
||||
.m-6 {
|
||||
margin: calc(var(--spacing) * 6);
|
||||
}
|
||||
@@ -5279,6 +5300,15 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.-mx-2 {
|
||||
margin-inline: calc(var(--spacing) * -2);
|
||||
}
|
||||
.mx-6 {
|
||||
margin-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
.mx-10 {
|
||||
margin-inline: calc(var(--spacing) * 10);
|
||||
}
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
@@ -5580,14 +5610,17 @@
|
||||
border-width: var(--border, 1px) 0 var(--border, 1px) var(--border, 1px);
|
||||
}
|
||||
}
|
||||
.ms-10 {
|
||||
margin-inline-start: calc(var(--spacing) * 10);
|
||||
}
|
||||
.me-2 {
|
||||
margin-inline-end: calc(var(--spacing) * 2);
|
||||
}
|
||||
.me-4 {
|
||||
margin-inline-end: calc(var(--spacing) * 4);
|
||||
}
|
||||
.me-6 {
|
||||
margin-inline-end: calc(var(--spacing) * 6);
|
||||
.me-10 {
|
||||
margin-inline-end: calc(var(--spacing) * 10);
|
||||
}
|
||||
.me-\[140px\] {
|
||||
margin-inline-end: 140px;
|
||||
@@ -5634,12 +5667,21 @@
|
||||
.-mt-2 {
|
||||
margin-top: calc(var(--spacing) * -2);
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: calc(var(--spacing) * 6);
|
||||
}
|
||||
.mt-10 {
|
||||
margin-top: calc(var(--spacing) * 10);
|
||||
}
|
||||
.mt-30 {
|
||||
margin-top: calc(var(--spacing) * 30);
|
||||
}
|
||||
.breadcrumbs {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
max-width: 100%;
|
||||
@@ -5696,6 +5738,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.mr-2 {
|
||||
margin-right: calc(var(--spacing) * 2);
|
||||
}
|
||||
.fieldset-legend {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
margin-bottom: calc(0.25rem * -1);
|
||||
@@ -5716,6 +5761,9 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
@@ -6218,6 +6266,9 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -6437,6 +6488,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.\!h-auto {
|
||||
height: auto !important;
|
||||
}
|
||||
.h-3 {
|
||||
height: calc(var(--spacing) * 3);
|
||||
}
|
||||
.h-4 {
|
||||
height: calc(var(--spacing) * 4);
|
||||
}
|
||||
.h-5 {
|
||||
height: calc(var(--spacing) * 5);
|
||||
}
|
||||
.h-6 {
|
||||
height: calc(var(--spacing) * 6);
|
||||
}
|
||||
.h-8 {
|
||||
height: calc(var(--spacing) * 8);
|
||||
}
|
||||
.h-10 {
|
||||
height: calc(var(--spacing) * 10);
|
||||
}
|
||||
.h-12 {
|
||||
height: calc(var(--spacing) * 12);
|
||||
}
|
||||
.h-14 {
|
||||
height: calc(var(--spacing) * 14);
|
||||
}
|
||||
.h-\[1em\] {
|
||||
height: 1em;
|
||||
}
|
||||
@@ -6446,6 +6524,21 @@
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
.min-h-6 {
|
||||
min-height: calc(var(--spacing) * 6);
|
||||
}
|
||||
.min-h-8 {
|
||||
min-height: calc(var(--spacing) * 8);
|
||||
}
|
||||
.min-h-10 {
|
||||
min-height: calc(var(--spacing) * 10);
|
||||
}
|
||||
.min-h-12 {
|
||||
min-height: calc(var(--spacing) * 12);
|
||||
}
|
||||
.min-h-14 {
|
||||
min-height: calc(var(--spacing) * 14);
|
||||
}
|
||||
.min-h-screen {
|
||||
min-height: 100vh;
|
||||
}
|
||||
@@ -6577,6 +6670,24 @@
|
||||
width: calc(var(--size-selector, 0.25rem) * 4);
|
||||
}
|
||||
}
|
||||
.w-3 {
|
||||
width: calc(var(--spacing) * 3);
|
||||
}
|
||||
.w-4 {
|
||||
width: calc(var(--spacing) * 4);
|
||||
}
|
||||
.w-5 {
|
||||
width: calc(var(--spacing) * 5);
|
||||
}
|
||||
.w-6 {
|
||||
width: calc(var(--spacing) * 6);
|
||||
}
|
||||
.w-52 {
|
||||
width: calc(var(--spacing) * 52);
|
||||
}
|
||||
.w-\[250px\] {
|
||||
width: 250px;
|
||||
}
|
||||
.w-\[330px\] {
|
||||
width: 330px;
|
||||
}
|
||||
@@ -6586,6 +6697,12 @@
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.w-px {
|
||||
width: 1px;
|
||||
}
|
||||
.min-w-0 {
|
||||
min-width: calc(var(--spacing) * 0);
|
||||
}
|
||||
.min-w-\[200px\] {
|
||||
min-width: 200px;
|
||||
}
|
||||
@@ -6604,6 +6721,12 @@
|
||||
.flex-shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.flex-grow {
|
||||
flex-grow: 1;
|
||||
}
|
||||
@@ -6626,6 +6749,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.-rotate-45 {
|
||||
rotate: calc(45deg * -1);
|
||||
}
|
||||
.rotate-180 {
|
||||
rotate: 180deg;
|
||||
}
|
||||
.swap-flip {
|
||||
@layer daisyui.l1.l2 {
|
||||
transform-style: preserve-3d;
|
||||
@@ -6681,6 +6810,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.cursor-not-allowed {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
@@ -6719,6 +6854,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.alert-horizontal {
|
||||
@layer daisyui.l1.l2 {
|
||||
justify-content: start;
|
||||
@@ -6785,6 +6923,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.grid-flow-col {
|
||||
grid-auto-flow: column;
|
||||
}
|
||||
.grid-flow-row {
|
||||
grid-auto-flow: row;
|
||||
}
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
@@ -6797,6 +6941,9 @@
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -6821,22 +6968,21 @@
|
||||
.justify-start {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.gap-0 {
|
||||
gap: calc(var(--spacing) * 0);
|
||||
}
|
||||
.gap-1 {
|
||||
gap: calc(var(--spacing) * 1);
|
||||
}
|
||||
.gap-2 {
|
||||
gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
.gap-3 {
|
||||
gap: calc(var(--spacing) * 3);
|
||||
}
|
||||
.gap-4 {
|
||||
gap: calc(var(--spacing) * 4);
|
||||
}
|
||||
.space-y-1 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));
|
||||
margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.space-y-2 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
@@ -6844,6 +6990,13 @@
|
||||
margin-block-end: calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.space-y-3 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
margin-block-start: calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));
|
||||
margin-block-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.space-y-4 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-y-reverse: 0;
|
||||
@@ -6858,6 +7011,9 @@
|
||||
margin-block-end: calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)));
|
||||
}
|
||||
}
|
||||
.gap-x-2 {
|
||||
column-gap: calc(var(--spacing) * 2);
|
||||
}
|
||||
.space-x-2 {
|
||||
:where(& > :not(:last-child)) {
|
||||
--tw-space-x-reverse: 0;
|
||||
@@ -6885,6 +7041,9 @@
|
||||
.overflow-auto {
|
||||
overflow: auto;
|
||||
}
|
||||
.overflow-hidden {
|
||||
overflow: hidden;
|
||||
}
|
||||
.timeline-box {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
border: var(--border) solid;
|
||||
@@ -6967,6 +7126,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.rounded-3xl {
|
||||
border-radius: var(--radius-3xl);
|
||||
}
|
||||
.rounded-box {
|
||||
border-radius: var(--radius-box);
|
||||
}
|
||||
@@ -6979,6 +7144,9 @@
|
||||
.rounded-field {
|
||||
border-radius: var(--radius-field);
|
||||
}
|
||||
.rounded-full {
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
.rounded-lg {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
@@ -7170,10 +7338,18 @@
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 2px;
|
||||
}
|
||||
.border-3 {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 3px;
|
||||
}
|
||||
.border-b-1 {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.border-b-6 {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 6px;
|
||||
}
|
||||
.badge-dash {
|
||||
@layer daisyui.l1.l2 {
|
||||
color: var(--badge-color);
|
||||
@@ -7283,6 +7459,12 @@
|
||||
border-color: currentColor;
|
||||
}
|
||||
}
|
||||
.border-accent {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
.border-base-300 {
|
||||
border-color: var(--color-base-300);
|
||||
}
|
||||
.border-error {
|
||||
border-color: var(--color-error);
|
||||
}
|
||||
@@ -7292,6 +7474,9 @@
|
||||
.border-primary {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.border-success {
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
.menu-active {
|
||||
:where(:not(ul, details, .menu-title, .btn))& {
|
||||
@layer daisyui.l1.l2 {
|
||||
@@ -7443,21 +7628,21 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.bg-base-200 {
|
||||
background-color: var(--color-base-200);
|
||||
}
|
||||
.bg-base-300 {
|
||||
background-color: var(--color-base-300);
|
||||
}
|
||||
.bg-current {
|
||||
background-color: currentcolor;
|
||||
}
|
||||
.bg-error {
|
||||
background-color: var(--color-error);
|
||||
}
|
||||
.bg-red-500 {
|
||||
background-color: var(--color-red-500);
|
||||
}
|
||||
.bg-secondary {
|
||||
background-color: var(--color-secondary);
|
||||
}
|
||||
.bg-white {
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
.divider-accent {
|
||||
@layer daisyui.l1.l2 {
|
||||
&:before, &:after {
|
||||
@@ -7670,6 +7855,9 @@
|
||||
.mask-repeat {
|
||||
mask-repeat: repeat;
|
||||
}
|
||||
.stroke-current {
|
||||
stroke: currentcolor;
|
||||
}
|
||||
.checkbox-lg {
|
||||
@layer daisyui.l1.l2 {
|
||||
padding: 0.3125rem;
|
||||
@@ -7740,15 +7928,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.\!p-1\.5 {
|
||||
padding: calc(var(--spacing) * 1.5) !important;
|
||||
}
|
||||
.p-1 {
|
||||
padding: calc(var(--spacing) * 1);
|
||||
}
|
||||
.p-1\.5 {
|
||||
padding: calc(var(--spacing) * 1.5);
|
||||
}
|
||||
.p-2 {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
.p-2\.5 {
|
||||
padding: calc(var(--spacing) * 2.5);
|
||||
}
|
||||
.p-3 {
|
||||
padding: calc(var(--spacing) * 3);
|
||||
}
|
||||
.p-4 {
|
||||
padding: calc(var(--spacing) * 4);
|
||||
}
|
||||
.p-6 {
|
||||
padding: calc(var(--spacing) * 6);
|
||||
}
|
||||
.p-10 {
|
||||
padding: calc(var(--spacing) * 10);
|
||||
}
|
||||
.menu-title {
|
||||
@layer daisyui.l1.l2.l3 {
|
||||
padding-inline: calc(0.25rem * 3);
|
||||
@@ -7866,18 +8072,36 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.\!px-0 {
|
||||
padding-inline: calc(var(--spacing) * 0) !important;
|
||||
}
|
||||
.\!px-3 {
|
||||
padding-inline: calc(var(--spacing) * 3) !important;
|
||||
}
|
||||
.px-2 {
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
.px-3 {
|
||||
padding-inline: calc(var(--spacing) * 3);
|
||||
}
|
||||
.px-4 {
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
}
|
||||
.py-1\.5 {
|
||||
padding-block: calc(var(--spacing) * 1.5);
|
||||
.px-6 {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
.px-10 {
|
||||
padding-inline: calc(var(--spacing) * 10);
|
||||
}
|
||||
.py-1 {
|
||||
padding-block: calc(var(--spacing) * 1);
|
||||
}
|
||||
.py-2 {
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
.py-6 {
|
||||
padding-block: calc(var(--spacing) * 6);
|
||||
}
|
||||
.ps-2 {
|
||||
padding-inline-start: calc(var(--spacing) * 2);
|
||||
}
|
||||
@@ -7901,6 +8125,12 @@
|
||||
.pb-6 {
|
||||
padding-bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.file-input-lg {
|
||||
@layer daisyui.l1.l2 {
|
||||
--size: calc(var(--size-field, 0.25rem) * 12);
|
||||
@@ -7949,6 +8179,22 @@
|
||||
font-size: var(--text-3xl);
|
||||
line-height: var(--tw-leading, var(--text-3xl--line-height));
|
||||
}
|
||||
.text-4xl {
|
||||
font-size: var(--text-4xl);
|
||||
line-height: var(--tw-leading, var(--text-4xl--line-height));
|
||||
}
|
||||
.text-5xl {
|
||||
font-size: var(--text-5xl);
|
||||
line-height: var(--tw-leading, var(--text-5xl--line-height));
|
||||
}
|
||||
.text-8xl {
|
||||
font-size: var(--text-8xl);
|
||||
line-height: var(--tw-leading, var(--text-8xl--line-height));
|
||||
}
|
||||
.text-base {
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--tw-leading, var(--text-base--line-height));
|
||||
}
|
||||
.text-lg {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--tw-leading, var(--text-lg--line-height));
|
||||
@@ -7961,6 +8207,10 @@
|
||||
font-size: var(--text-xl);
|
||||
line-height: var(--tw-leading, var(--text-xl--line-height));
|
||||
}
|
||||
.text-xs {
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--tw-leading, var(--text-xs--line-height));
|
||||
}
|
||||
.tabs-lg {
|
||||
@layer daisyui.l1.l2 {
|
||||
--tab-height: calc(var(--size-field, 0.25rem) * 12);
|
||||
@@ -8515,6 +8765,9 @@
|
||||
color: var(--color-warning);
|
||||
}
|
||||
}
|
||||
.text-accent {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
.text-base-content {
|
||||
color: var(--color-base-content);
|
||||
}
|
||||
@@ -8530,15 +8783,12 @@
|
||||
.text-info {
|
||||
color: var(--color-info);
|
||||
}
|
||||
.text-slate-900 {
|
||||
color: var(--color-slate-900);
|
||||
.text-primary {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.text-success {
|
||||
color: var(--color-success);
|
||||
}
|
||||
.text-white {
|
||||
color: var(--color-white);
|
||||
}
|
||||
.lowercase {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
@@ -8600,9 +8850,18 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity-0 {
|
||||
opacity: 0%;
|
||||
}
|
||||
.opacity-30 {
|
||||
opacity: 30%;
|
||||
}
|
||||
.opacity-50 {
|
||||
opacity: 50%;
|
||||
}
|
||||
.opacity-60 {
|
||||
opacity: 60%;
|
||||
}
|
||||
.shadow-2xl {
|
||||
--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
@@ -8619,13 +8878,6 @@
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.outline-1 {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
}
|
||||
.-outline-offset-1 {
|
||||
outline-offset: calc(1px * -1);
|
||||
}
|
||||
.btn-ghost {
|
||||
@layer daisyui.l1 {
|
||||
&:not(.btn-active, :hover, :active:focus, :focus-visible, input:checked:not(.filter .btn)) {
|
||||
@@ -8650,9 +8902,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.outline-slate-300 {
|
||||
outline-color: var(--color-slate-300);
|
||||
}
|
||||
.filter {
|
||||
filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,);
|
||||
}
|
||||
@@ -8665,6 +8914,16 @@
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-opacity {
|
||||
transition-property: opacity;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-transform {
|
||||
transition-property: transform, translate, scale, rotate;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.ease-in-out {
|
||||
--tw-ease: var(--ease-in-out);
|
||||
transition-timing-function: var(--ease-in-out);
|
||||
@@ -9406,16 +9665,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.invalid\:border-red-500 {
|
||||
&:invalid {
|
||||
border-color: var(--color-red-500);
|
||||
}
|
||||
}
|
||||
.out-of-range\:border-red-500 {
|
||||
&:out-of-range {
|
||||
border-color: var(--color-red-500);
|
||||
}
|
||||
}
|
||||
.hover\:bg-neutral {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -9423,13 +9672,6 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:bg-red-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-red-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:text-white {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
@@ -9437,20 +9679,42 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus\:outline {
|
||||
&:focus {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
.hover\:opacity-75 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
opacity: 75%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus\:-outline-offset-2 {
|
||||
&:focus {
|
||||
outline-offset: calc(2px * -1);
|
||||
.hover\:opacity-100 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
opacity: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus\:outline-indigo-600 {
|
||||
.focus\:\!bg-transparent {
|
||||
&:focus {
|
||||
outline-color: var(--color-indigo-600);
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
.focus\:outline-none {
|
||||
&:focus {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
}
|
||||
}
|
||||
.active\:scale-100 {
|
||||
&:active {
|
||||
--tw-scale-x: 100%;
|
||||
--tw-scale-y: 100%;
|
||||
--tw-scale-z: 100%;
|
||||
scale: var(--tw-scale-x) var(--tw-scale-y);
|
||||
}
|
||||
}
|
||||
.active\:\!bg-transparent {
|
||||
&:active {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
}
|
||||
.data-active\:bg-neutral {
|
||||
@@ -9473,14 +9737,39 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.md\:mt-15 {
|
||||
@media (width >= 48rem) {
|
||||
margin-top: calc(var(--spacing) * 15);
|
||||
}
|
||||
}
|
||||
.md\:grid-cols-2 {
|
||||
@media (width >= 48rem) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
.md\:grid-cols-3 {
|
||||
.md\:justify-between {
|
||||
@media (width >= 48rem) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
.md\:place-self-center {
|
||||
@media (width >= 48rem) {
|
||||
place-self: center;
|
||||
}
|
||||
}
|
||||
.md\:place-self-end {
|
||||
@media (width >= 48rem) {
|
||||
place-self: end;
|
||||
}
|
||||
}
|
||||
.md\:place-self-start {
|
||||
@media (width >= 48rem) {
|
||||
place-self: start;
|
||||
}
|
||||
}
|
||||
.md\:justify-self-end {
|
||||
@media (width >= 48rem) {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
}
|
||||
.lg\:drawer-open {
|
||||
@@ -9536,9 +9825,14 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.lg\:grid-cols-4 {
|
||||
.lg\:mx-20 {
|
||||
@media (width >= 64rem) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
margin-inline: calc(var(--spacing) * 20);
|
||||
}
|
||||
}
|
||||
.lg\:mt-6 {
|
||||
@media (width >= 64rem) {
|
||||
margin-top: calc(var(--spacing) * 6);
|
||||
}
|
||||
}
|
||||
.is-drawer-close\:mx-auto {
|
||||
@@ -9580,16 +9874,6 @@
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
.is-drawer-close\:text-error {
|
||||
&:where(.drawer-toggle:not(:checked) ~ .drawer-side, .drawer-toggle:not(:checked) ~ .drawer-side *) {
|
||||
color: var(--color-error);
|
||||
}
|
||||
}
|
||||
.is-drawer-close\:text-green-400 {
|
||||
&:where(.drawer-toggle:not(:checked) ~ .drawer-side, .drawer-toggle:not(:checked) ~ .drawer-side *) {
|
||||
color: var(--color-green-400);
|
||||
}
|
||||
}
|
||||
.is-drawer-open\:flex {
|
||||
&:where(.drawer-toggle:checked ~ .drawer-side, .drawer-toggle:checked ~ .drawer-side *) {
|
||||
display: flex;
|
||||
@@ -11172,6 +11456,21 @@
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-scale-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 1;
|
||||
}
|
||||
@property --tw-scale-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 1;
|
||||
}
|
||||
@property --tw-scale-z {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 1;
|
||||
}
|
||||
@layer properties {
|
||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||
*, ::before, ::after, ::backdrop {
|
||||
@@ -11227,6 +11526,9 @@
|
||||
--tw-backdrop-saturate: initial;
|
||||
--tw-backdrop-sepia: initial;
|
||||
--tw-ease: initial;
|
||||
--tw-scale-x: 1;
|
||||
--tw-scale-y: 1;
|
||||
--tw-scale-z: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
3090
Public/js/daisy-multiselect.js
Normal file
3090
Public/js/daisy-multiselect.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,38 +0,0 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Logging
|
||||
import ManualDCore
|
||||
|
||||
extension DependencyValues {
|
||||
public var apiController: ApiController {
|
||||
get { self[ApiController.self] }
|
||||
set { self[ApiController.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct ApiController: Sendable {
|
||||
public var json: @Sendable (Request) async throws -> (any Encodable)?
|
||||
}
|
||||
|
||||
extension ApiController {
|
||||
public struct Request: Sendable {
|
||||
public let route: SiteRoute.Api
|
||||
public let logger: Logger
|
||||
|
||||
public init(route: SiteRoute.Api, logger: Logger) {
|
||||
self.route = route
|
||||
self.logger = logger
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ApiController: DependencyKey {
|
||||
public static let testValue = Self()
|
||||
|
||||
public static let liveValue = Self(
|
||||
json: { request in
|
||||
try await request.route.respond(logger: request.logger)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import Logging
|
||||
import ManualDCore
|
||||
|
||||
extension SiteRoute.Api {
|
||||
func respond(logger: Logger) async throws -> (any Encodable)? {
|
||||
switch self {
|
||||
case .project(let route):
|
||||
return try await route.respond(logger: logger)
|
||||
case .room(let route):
|
||||
return try await route.respond(logger: logger)
|
||||
case .equipment(let route):
|
||||
return try await route.respond(logger: logger)
|
||||
case .componentLoss(let route):
|
||||
return try await route.respond(logger: logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.Api.ProjectRoute {
|
||||
|
||||
func respond(logger: Logger) async throws -> (any Encodable)? {
|
||||
@Dependency(\.database) var database
|
||||
|
||||
switch self {
|
||||
case .create(let request):
|
||||
// return try await database.projects.create(request)
|
||||
// FIX:
|
||||
fatalError()
|
||||
case .delete(let id):
|
||||
try await database.projects.delete(id)
|
||||
return nil
|
||||
case .detail(let id, let route):
|
||||
switch route {
|
||||
case .index:
|
||||
return try await database.projects.detail(id)
|
||||
case .completedSteps:
|
||||
// FIX:
|
||||
fatalError()
|
||||
|
||||
}
|
||||
case .get(let id):
|
||||
guard let project = try await database.projects.get(id) else {
|
||||
logger.error("Project not found for id: \(id)")
|
||||
throw ApiError("Project not found.")
|
||||
}
|
||||
return project
|
||||
case .index:
|
||||
// FIX: Fix to return projects.
|
||||
return [Project]()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.Api.RoomRoute {
|
||||
|
||||
func respond(logger: Logger) async throws -> (any Encodable)? {
|
||||
@Dependency(\.database) var database
|
||||
|
||||
switch self {
|
||||
case .create(let request):
|
||||
return try await database.rooms.create(request)
|
||||
case .delete(let id):
|
||||
try await database.rooms.delete(id)
|
||||
return nil
|
||||
case .get(let id):
|
||||
guard let room = try await database.rooms.get(id) else {
|
||||
logger.error("Room not found for id: \(id)")
|
||||
throw ApiError("Room not found.")
|
||||
}
|
||||
return room
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.Api.EquipmentRoute {
|
||||
|
||||
func respond(logger: Logger) async throws -> (any Encodable)? {
|
||||
@Dependency(\.database) var database
|
||||
|
||||
switch self {
|
||||
case .create(let request):
|
||||
return try await database.equipment.create(request)
|
||||
case .delete(let id):
|
||||
try await database.equipment.delete(id)
|
||||
return nil
|
||||
case .fetch(let projectID):
|
||||
return try await database.equipment.fetch(projectID)
|
||||
case .get(let id):
|
||||
guard let room = try await database.equipment.get(id) else {
|
||||
logger.error("Equipment not found for id: \(id)")
|
||||
throw ApiError("Equipment not found.")
|
||||
}
|
||||
return room
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.Api.ComponentLossRoute {
|
||||
|
||||
func respond(logger: Logger) async throws -> (any Encodable)? {
|
||||
@Dependency(\.database) var database
|
||||
|
||||
switch self {
|
||||
case .create(let request):
|
||||
return try await database.componentLosses.create(request)
|
||||
case .delete(let id):
|
||||
try await database.componentLosses.delete(id)
|
||||
return nil
|
||||
case .fetch(let projectID):
|
||||
return try await database.componentLosses.fetch(projectID)
|
||||
case .get(let id):
|
||||
guard let room = try await database.componentLosses.get(id) else {
|
||||
logger.error("Component loss not found for id: \(id)")
|
||||
throw ApiError("Component loss not found.")
|
||||
}
|
||||
return room
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct ApiError: Error {
|
||||
let message: String
|
||||
|
||||
init(_ message: String) {
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
@@ -1,37 +1,37 @@
|
||||
import ApiController
|
||||
import ManualDCore
|
||||
import Vapor
|
||||
|
||||
extension ApiController {
|
||||
|
||||
func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws
|
||||
-> any AsyncResponseEncodable
|
||||
{
|
||||
guard let encodable = try await json(.init(route: route, logger: request.logger)) else {
|
||||
return HTTPStatus.ok
|
||||
}
|
||||
return AnyJSONResponse(value: encodable)
|
||||
}
|
||||
}
|
||||
|
||||
struct AnyJSONResponse: AsyncResponseEncodable {
|
||||
public var headers: HTTPHeaders = ["Content-Type": "application/json"]
|
||||
let value: any Encodable
|
||||
|
||||
init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) {
|
||||
if additionalHeaders.contains(name: .contentType) {
|
||||
self.headers = additionalHeaders
|
||||
} else {
|
||||
headers.add(contentsOf: additionalHeaders)
|
||||
}
|
||||
self.value = value
|
||||
}
|
||||
|
||||
func encodeResponse(for request: Request) async throws -> Response {
|
||||
try Response(
|
||||
status: .ok,
|
||||
headers: headers,
|
||||
body: .init(data: JSONEncoder().encode(value))
|
||||
)
|
||||
}
|
||||
}
|
||||
// import ApiController
|
||||
// import ManualDCore
|
||||
// import Vapor
|
||||
//
|
||||
// extension ApiController {
|
||||
//
|
||||
// func respond(_ route: SiteRoute.Api, request: Vapor.Request) async throws
|
||||
// -> any AsyncResponseEncodable
|
||||
// {
|
||||
// guard let encodable = try await json(.init(route: route, logger: request.logger)) else {
|
||||
// return HTTPStatus.ok
|
||||
// }
|
||||
// return AnyJSONResponse(value: encodable)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// struct AnyJSONResponse: AsyncResponseEncodable {
|
||||
// public var headers: HTTPHeaders = ["Content-Type": "application/json"]
|
||||
// let value: any Encodable
|
||||
//
|
||||
// init(additionalHeaders: HTTPHeaders = [:], value: any Encodable) {
|
||||
// if additionalHeaders.contains(name: .contentType) {
|
||||
// self.headers = additionalHeaders
|
||||
// } else {
|
||||
// headers.add(contentsOf: additionalHeaders)
|
||||
// }
|
||||
// self.value = value
|
||||
// }
|
||||
//
|
||||
// func encodeResponse(for request: Request) async throws -> Response {
|
||||
// try Response(
|
||||
// status: .ok,
|
||||
// headers: headers,
|
||||
// body: .init(data: JSONEncoder().encode(value))
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import ApiController
|
||||
// import ApiController
|
||||
import AuthClient
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import EnvVars
|
||||
import ManualDCore
|
||||
import PdfClient
|
||||
import Vapor
|
||||
@@ -12,27 +13,31 @@ import ViewController
|
||||
struct DependenciesMiddleware: AsyncMiddleware {
|
||||
|
||||
private let values: DependencyValues.Continuation
|
||||
private let apiController: ApiController
|
||||
// private let apiController: ApiController
|
||||
private let database: DatabaseClient
|
||||
private let environment: EnvVars
|
||||
private let viewController: ViewController
|
||||
|
||||
init(
|
||||
database: DatabaseClient,
|
||||
apiController: ApiController = .liveValue,
|
||||
environment: EnvVars,
|
||||
// apiController: ApiController = .liveValue,
|
||||
viewController: ViewController = .liveValue
|
||||
) {
|
||||
self.values = withEscapedDependencies { $0 }
|
||||
self.apiController = apiController
|
||||
// self.apiController = apiController
|
||||
self.database = database
|
||||
self.environment = environment
|
||||
self.viewController = viewController
|
||||
}
|
||||
|
||||
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
|
||||
try await values.yield {
|
||||
try await withDependencies {
|
||||
$0.apiController = apiController
|
||||
// $0.apiController = apiController
|
||||
$0.auth = .live(on: request)
|
||||
$0.database = database
|
||||
$0.environment = environment
|
||||
// $0.dateFormatter = .liveValue
|
||||
$0.viewController = viewController
|
||||
$0.pdfClient = .liveValue
|
||||
|
||||
@@ -14,7 +14,7 @@ private let viewRouteMiddleware: [any Middleware] = [
|
||||
extension SiteRoute.View {
|
||||
var middleware: [any Middleware]? {
|
||||
switch self {
|
||||
case .login, .signup, .test:
|
||||
case .home, .login, .signup, .test, .ductulator, .privacyPolicy:
|
||||
return nil
|
||||
case .project, .user:
|
||||
return viewRouteMiddleware
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import Elementary
|
||||
import EnvVars
|
||||
import Fluent
|
||||
import FluentPostgresDriver
|
||||
import FluentSQLiteDriver
|
||||
import ManualDCore
|
||||
import NIOSSL
|
||||
@@ -14,12 +16,15 @@ import ViewController
|
||||
// configures your application
|
||||
public func configure(
|
||||
_ app: Application,
|
||||
in environment: EnvVars,
|
||||
makeDatabaseClient: @escaping (any Database) -> DatabaseClient = { .live(database: $0) }
|
||||
) async throws {
|
||||
// Setup the database client.
|
||||
let databaseClient = try await setupDatabase(on: app, factory: makeDatabaseClient)
|
||||
let databaseClient = try await setupDatabase(
|
||||
on: app, environment: environment, factory: makeDatabaseClient
|
||||
)
|
||||
// Add the global middlewares.
|
||||
addMiddleware(to: app, database: databaseClient)
|
||||
addMiddleware(to: app, database: databaseClient, environment: environment)
|
||||
#if DEBUG
|
||||
// Live reload of the application for development when launched with the `./swift-dev` command
|
||||
// app.lifecycle.use(BrowserSyncHandler())
|
||||
@@ -33,7 +38,11 @@ public func configure(
|
||||
addCommands(to: app)
|
||||
}
|
||||
|
||||
private func addMiddleware(to app: Application, database databaseClient: DatabaseClient) {
|
||||
private func addMiddleware(
|
||||
to app: Application,
|
||||
database databaseClient: DatabaseClient,
|
||||
environment: EnvVars
|
||||
) {
|
||||
// cors middleware should come before default error middleware using `at: .beginning`
|
||||
let corsConfiguration = CORSMiddleware.Configuration(
|
||||
allowedOrigin: .all,
|
||||
@@ -48,16 +57,20 @@ private func addMiddleware(to app: Application, database databaseClient: Databas
|
||||
|
||||
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
|
||||
app.middleware.use(app.sessions.middleware)
|
||||
app.middleware.use(DependenciesMiddleware(database: databaseClient))
|
||||
app.middleware.use(DependenciesMiddleware(database: databaseClient, environment: environment))
|
||||
}
|
||||
|
||||
private func setupDatabase(
|
||||
on app: Application,
|
||||
environment: EnvVars,
|
||||
factory makeDatabaseClient: @escaping (any Database) -> DatabaseClient
|
||||
) async throws -> DatabaseClient {
|
||||
switch app.environment {
|
||||
case .production, .development:
|
||||
let dbFileName = Environment.get("SQLITE_FILENAME") ?? "db.sqlite"
|
||||
case .production:
|
||||
let configuration = try environment.postgresConfiguration()
|
||||
app.databases.use(.postgres(configuration: configuration), as: .psql)
|
||||
case .development:
|
||||
let dbFileName = environment.sqlitePath ?? "db.sqlite"
|
||||
app.databases.use(DatabaseConfigurationFactory.sqlite(.file(dbFileName)), as: .sqlite)
|
||||
default:
|
||||
app.databases.use(DatabaseConfigurationFactory.sqlite(.memory), as: .sqlite)
|
||||
@@ -100,8 +113,6 @@ extension SiteRoute {
|
||||
|
||||
fileprivate func middleware() -> [any Middleware]? {
|
||||
switch self {
|
||||
case .api:
|
||||
return nil
|
||||
case .health:
|
||||
return nil
|
||||
case .view(let route):
|
||||
@@ -117,13 +128,10 @@ private func siteHandler(
|
||||
request: Request,
|
||||
route: SiteRoute
|
||||
) async throws -> any AsyncResponseEncodable {
|
||||
@Dependency(\.apiController) var apiController
|
||||
@Dependency(\.viewController) var viewController
|
||||
@Dependency(\.projectClient) var projectClient
|
||||
|
||||
switch route {
|
||||
case .api(let route):
|
||||
return try await apiController.respond(route, request: request)
|
||||
case .health:
|
||||
return HTTPStatus.ok
|
||||
// Generating a pdf return's a `Response` instead of `HTML` like other views, so we
|
||||
@@ -134,3 +142,30 @@ private func siteHandler(
|
||||
return try await viewController.respond(route: route, request: request)
|
||||
}
|
||||
}
|
||||
|
||||
extension EnvVars {
|
||||
func postgresConfiguration() throws -> SQLPostgresConfiguration {
|
||||
guard let hostname = postgresHostname,
|
||||
let username = postgresUsername,
|
||||
let password = postgresPassword,
|
||||
let database = postgresDatabase
|
||||
else {
|
||||
throw EnvError("Missing environment variables for postgres connection.")
|
||||
}
|
||||
return .init(
|
||||
hostname: hostname,
|
||||
username: username,
|
||||
password: password,
|
||||
database: database,
|
||||
tls: .disable
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
struct EnvError: Error {
|
||||
let reason: String
|
||||
|
||||
init(_ reason: String) {
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import EnvVars
|
||||
import Logging
|
||||
import NIOCore
|
||||
import NIOPosix
|
||||
@@ -17,11 +18,15 @@ enum Entrypoint {
|
||||
// You can enable it if you'd like to reduce the amount of context switching between NIO and Swift Concurrency.
|
||||
// Note: this has caused issues with some libraries that use `.wait()` and cleanly shutting down.
|
||||
// If enabled, you should be careful about calling async functions before this point as it can cause assertion failures.
|
||||
// let executorTakeoverSuccess = NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
|
||||
// app.logger.debug("Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor", metadata: ["success": .stringConvertible(executorTakeoverSuccess)])
|
||||
let executorTakeoverSuccess =
|
||||
NIOSingletons.unsafeTryInstallSingletonPosixEventLoopGroupAsConcurrencyGlobalExecutor()
|
||||
app.logger.debug(
|
||||
"Tried to install SwiftNIO's EventLoopGroup as Swift's global concurrency executor",
|
||||
metadata: ["success": .stringConvertible(executorTakeoverSuccess)]
|
||||
)
|
||||
|
||||
do {
|
||||
try await configure(app)
|
||||
try await configure(app, in: EnvVars.live())
|
||||
} catch {
|
||||
app.logger.report(error: error)
|
||||
try? await app.asyncShutdown()
|
||||
|
||||
@@ -38,6 +38,7 @@ extension AuthClient: TestDependencyKey {
|
||||
.init(email: createForm.email, password: createForm.password)
|
||||
)
|
||||
request.auth.login(user)
|
||||
request.session.authenticate(user)
|
||||
request.logger.debug("LOGGED IN: \(user.id)")
|
||||
return user
|
||||
},
|
||||
|
||||
14
Sources/CLI/Cli.swift
Normal file
14
Sources/CLI/Cli.swift
Normal file
@@ -0,0 +1,14 @@
|
||||
import ArgumentParser
|
||||
|
||||
@main
|
||||
struct DuctCalcCli: AsyncParsableCommand {
|
||||
static let configuration: CommandConfiguration = .init(
|
||||
commandName: "ductcalc",
|
||||
abstract: "Perform duct calculations.",
|
||||
subcommands: [
|
||||
ConvertCommand.self,
|
||||
SizeCommand.self,
|
||||
],
|
||||
defaultSubcommand: SizeCommand.self
|
||||
)
|
||||
}
|
||||
35
Sources/CLI/Commands/Convert.swift
Normal file
35
Sources/CLI/Commands/Convert.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import ArgumentParser
|
||||
import Dependencies
|
||||
import ManualDClient
|
||||
|
||||
struct ConvertCommand: AsyncParsableCommand {
|
||||
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "convert",
|
||||
abstract: "Convert to an equivalent recangular size."
|
||||
)
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "The height"
|
||||
)
|
||||
var height: Int
|
||||
|
||||
@Argument(
|
||||
// name: .shortAndLong,
|
||||
help: "The round size."
|
||||
)
|
||||
var roundSize: Int
|
||||
|
||||
func run() async throws {
|
||||
@Dependency(\.manualD) var manualD
|
||||
|
||||
let size = try await manualD.rectangularSize(
|
||||
round: .init(roundSize),
|
||||
height: .init(height)
|
||||
)
|
||||
|
||||
print("\(size.width) x \(height)")
|
||||
}
|
||||
|
||||
}
|
||||
35
Sources/CLI/Commands/Size.swift
Normal file
35
Sources/CLI/Commands/Size.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import ArgumentParser
|
||||
import Dependencies
|
||||
import ManualDClient
|
||||
|
||||
struct SizeCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "size",
|
||||
abstract: "Calculate the required size of a duct."
|
||||
)
|
||||
|
||||
@Option(
|
||||
name: .shortAndLong,
|
||||
help: "The design friction rate."
|
||||
)
|
||||
var frictionRate: Double = 0.06
|
||||
|
||||
@Argument(
|
||||
help: "The required CFM for the duct."
|
||||
)
|
||||
var cfm: Int
|
||||
|
||||
func run() async throws {
|
||||
@Dependency(\.manualD) var manualD
|
||||
|
||||
let size = try await manualD.ductSize(cfm: cfm, frictionRate: frictionRate)
|
||||
print(
|
||||
"""
|
||||
Calculated: \(size.calculatedSize.string(digits: 2))
|
||||
Final Size: \(size.finalSize)
|
||||
Flex Size: \(size.flexSize)
|
||||
"""
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
42
Sources/CSVParser/Interface.swift
Normal file
42
Sources/CSVParser/Interface.swift
Normal file
@@ -0,0 +1,42 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import ManualDCore
|
||||
import Parsing
|
||||
|
||||
extension DependencyValues {
|
||||
public var csvParser: CSVParser {
|
||||
get { self[CSVParser.self] }
|
||||
set { self[CSVParser.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
public struct CSVParser: Sendable {
|
||||
public var parseRooms: @Sendable (Room.CSV) async throws -> [Room.CSV.Row]
|
||||
}
|
||||
|
||||
extension CSVParser: DependencyKey {
|
||||
public static let testValue = Self()
|
||||
|
||||
public static let liveValue = Self(
|
||||
parseRooms: { csv in
|
||||
guard let string = String(data: csv.file, encoding: .utf8) else {
|
||||
throw CSVParsingError("Unreadable file data")
|
||||
}
|
||||
let rows = try RoomCSVParser().parse(string[...].utf8)
|
||||
return rows.reduce(into: [Room.CSV.Row]()) {
|
||||
if case .room(let room) = $1 {
|
||||
$0.append(room)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
public struct CSVParsingError: Error {
|
||||
let reason: String
|
||||
|
||||
public init(_ reason: String) {
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
64
Sources/CSVParser/Internal/Room+parsing.swift
Normal file
64
Sources/CSVParser/Internal/Room+parsing.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import ManualDCore
|
||||
import Parsing
|
||||
|
||||
struct RoomCSVParser: Parser {
|
||||
var body: some Parser<Substring.UTF8View, [RoomRowType]> {
|
||||
Many {
|
||||
RoomRowParser()
|
||||
} separator: {
|
||||
"\n".utf8
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RoomRowParser: Parser {
|
||||
|
||||
var body: some Parser<Substring.UTF8View, RoomRowType> {
|
||||
OneOf {
|
||||
RoomCreateParser().map { RoomRowType.room($0) }
|
||||
Prefix { $0 != UInt8(ascii: "\n") }
|
||||
.map(.string)
|
||||
.map { RoomRowType.header($0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RoomRowType {
|
||||
case header(String)
|
||||
case room(Room.CSV.Row)
|
||||
}
|
||||
|
||||
struct RoomCreateParser: ParserPrinter {
|
||||
|
||||
// FIX: The delegated to field won't work here, as we potentially have not created
|
||||
// the room yet, so we will need an intermediate representation for the csv data
|
||||
// that uses a room's name or disregard and require user to delegate airflow in
|
||||
// the ui.
|
||||
var body: some ParserPrinter<Substring.UTF8View, Room.CSV.Row> {
|
||||
ParsePrint {
|
||||
Prefix { $0 != UInt8(ascii: ",") }.map(.string)
|
||||
",".utf8
|
||||
Optionally {
|
||||
Int.parser()
|
||||
.map(.memberwise(Room.Level.init(rawValue:)))
|
||||
}
|
||||
",".utf8
|
||||
Double.parser()
|
||||
",".utf8
|
||||
Optionally {
|
||||
Double.parser()
|
||||
}
|
||||
",".utf8
|
||||
Optionally {
|
||||
Double.parser()
|
||||
}
|
||||
",".utf8
|
||||
Int.parser()
|
||||
",".utf8
|
||||
Optionally {
|
||||
Prefix { $0 != UInt8(ascii: "\n") }.map(.string)
|
||||
}
|
||||
}
|
||||
.map(.memberwise(Room.CSV.Row.init))
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,9 @@ public struct DatabaseClient: Sendable {
|
||||
|
||||
@DependencyClient
|
||||
public struct Rooms: Sendable {
|
||||
public var create: @Sendable (Room.Create) async throws -> Room
|
||||
public var create: @Sendable (Project.ID, Room.Create) async throws -> Room
|
||||
public var createMany: @Sendable (Project.ID, [Room.Create]) async throws -> [Room]
|
||||
public var createFromCSV: @Sendable (Project.ID, [Room.CSV.Row]) async throws -> [Room]
|
||||
public var delete: @Sendable (Room.ID) async throws -> Void
|
||||
public var deleteRectangularSize:
|
||||
@Sendable (Room.ID, Room.RectangularSize.ID) async throws -> Room
|
||||
@@ -97,6 +99,7 @@ public struct DatabaseClient: Sendable {
|
||||
public var fetch: @Sendable (Project.ID) async throws -> [Room]
|
||||
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
|
||||
public var updateRectangularSize: @Sendable (Room.ID, Room.RectangularSize) async throws -> Room
|
||||
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
|
||||
48
Sources/DatabaseClient/Internal/Array+validator.swift
Normal file
48
Sources/DatabaseClient/Internal/Array+validator.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
import Validations
|
||||
|
||||
extension Validator {
|
||||
|
||||
static func validate<Child: Validatable>(
|
||||
_ toChild: KeyPath<Value, [Child]>
|
||||
)
|
||||
-> Self
|
||||
{
|
||||
self.mapValue({ $0[keyPath: toChild] }, with: ArrayValidator())
|
||||
}
|
||||
|
||||
static func validate<Child: Validatable>(
|
||||
_ toChild: KeyPath<Value, [Child]?>
|
||||
)
|
||||
-> Self
|
||||
{
|
||||
self.mapValue({ $0[keyPath: toChild] }, with: ArrayValidator().optional())
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element: Validatable {
|
||||
static func validator() -> some Validation<Self> {
|
||||
ArrayValidator<Element>()
|
||||
}
|
||||
}
|
||||
|
||||
struct ArrayValidator<Element>: Validation where Element: Validatable {
|
||||
func validate(_ value: [Element]) throws {
|
||||
for item in value {
|
||||
try item.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ForEachValidator<T, E>: Validation where T: Validation, T.Value == E {
|
||||
let validator: T
|
||||
|
||||
init(@ValidationBuilder<E> builder: () -> T) {
|
||||
self.validator = builder()
|
||||
}
|
||||
|
||||
func validate(_ value: [E]) throws {
|
||||
for item in value {
|
||||
try validator.validate(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import SQLKit
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.ComponentLosses: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
@@ -13,8 +14,8 @@ extension DatabaseClient.ComponentLosses {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
let model = request.toModel()
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -35,13 +36,13 @@ extension DatabaseClient.ComponentLosses {
|
||||
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
|
||||
},
|
||||
update: { id, updates in
|
||||
try updates.validate()
|
||||
// try updates.validate()
|
||||
guard let model = try await ComponentLossModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -51,40 +52,9 @@ extension DatabaseClient.ComponentLosses {
|
||||
|
||||
extension ComponentPressureLoss.Create {
|
||||
|
||||
func toModel() throws(ValidationError) -> ComponentLossModel {
|
||||
try validate()
|
||||
func toModel() -> ComponentLossModel {
|
||||
return .init(name: name, value: value, projectID: projectID)
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Component loss name should not be empty.")
|
||||
}
|
||||
guard value > 0 else {
|
||||
throw ValidationError("Component loss value should be greater than 0.")
|
||||
}
|
||||
guard value < 1.0 else {
|
||||
throw ValidationError("Component loss value should be less than 1.0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComponentPressureLoss.Update {
|
||||
func validate() throws(ValidationError) {
|
||||
if let name {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Component loss name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let value {
|
||||
guard value > 0 else {
|
||||
throw ValidationError("Component loss value should be greater than 0.")
|
||||
}
|
||||
guard value < 1.0 else {
|
||||
throw ValidationError("Component loss value should be less than 1.0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComponentPressureLoss {
|
||||
@@ -96,8 +66,8 @@ extension ComponentPressureLoss {
|
||||
.id()
|
||||
.field("name", .string, .required)
|
||||
.field("value", .double, .required)
|
||||
.field("createdAt", .datetime)
|
||||
.field("updatedAt", .datetime)
|
||||
.field("createdAt", .string)
|
||||
.field("updatedAt", .string)
|
||||
.field(
|
||||
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||
)
|
||||
@@ -171,3 +141,19 @@ final class ComponentLossModel: Model, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension ComponentLossModel: Validatable {
|
||||
|
||||
var body: some Validation<ComponentLossModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.name, with: .notEmpty())
|
||||
.errorLabel("Name", inline: true)
|
||||
|
||||
Validator.validate(\.value) {
|
||||
Double.greaterThan(0.0)
|
||||
Double.lessThanOrEquals(1.0)
|
||||
}
|
||||
.errorLabel("Value", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.Equipment: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
@@ -10,8 +11,8 @@ extension DatabaseClient.Equipment: TestDependencyKey {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
let model = request.toModel()
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -37,10 +38,9 @@ extension DatabaseClient.Equipment: TestDependencyKey {
|
||||
guard let model = try await EquipmentModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
try updates.validate()
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -50,8 +50,7 @@ extension DatabaseClient.Equipment: TestDependencyKey {
|
||||
|
||||
extension EquipmentInfo.Create {
|
||||
|
||||
func toModel() throws(ValidationError) -> EquipmentModel {
|
||||
try validate()
|
||||
func toModel() -> EquipmentModel {
|
||||
return .init(
|
||||
staticPressure: staticPressure,
|
||||
heatingCFM: heatingCFM,
|
||||
@@ -60,44 +59,6 @@ extension EquipmentInfo.Create {
|
||||
)
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard staticPressure >= 0 else {
|
||||
throw ValidationError("Equipment info static pressure should be greater than 0.")
|
||||
}
|
||||
guard staticPressure <= 1.0 else {
|
||||
throw ValidationError("Equipment info static pressure should be less than 1.0.")
|
||||
}
|
||||
guard heatingCFM >= 0 else {
|
||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
||||
}
|
||||
guard coolingCFM >= 0 else {
|
||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EquipmentInfo.Update {
|
||||
var hasUpdates: Bool {
|
||||
staticPressure != nil || heatingCFM != nil || coolingCFM != nil
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
if let staticPressure {
|
||||
guard staticPressure >= 0 else {
|
||||
throw ValidationError("Equipment info static pressure should be greater than 0.")
|
||||
}
|
||||
}
|
||||
if let heatingCFM {
|
||||
guard heatingCFM >= 0 else {
|
||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
||||
}
|
||||
}
|
||||
if let coolingCFM {
|
||||
guard coolingCFM >= 0 else {
|
||||
throw ValidationError("Equipment info heating CFM should be greater than 0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EquipmentInfo {
|
||||
@@ -112,8 +73,8 @@ extension EquipmentInfo {
|
||||
.field("staticPressure", .double, .required)
|
||||
.field("heatingCFM", .int16, .required)
|
||||
.field("coolingCFM", .int16, .required)
|
||||
.field("createdAt", .datetime)
|
||||
.field("updatedAt", .datetime)
|
||||
.field("createdAt", .string)
|
||||
.field("updatedAt", .string)
|
||||
.field(
|
||||
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||
)
|
||||
@@ -197,3 +158,22 @@ final class EquipmentModel: Model, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EquipmentModel: Validatable {
|
||||
|
||||
var body: some Validation<EquipmentModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.staticPressure) {
|
||||
Double.greaterThan(0.0)
|
||||
Double.lessThan(1.0)
|
||||
}
|
||||
.errorLabel("Static Pressure", inline: true)
|
||||
|
||||
Validator.validate(\.heatingCFM, with: .greaterThan(0))
|
||||
.errorLabel("Heating CFM", inline: true)
|
||||
|
||||
Validator.validate(\.coolingCFM, with: .greaterThan(0))
|
||||
.errorLabel("Cooling CFM", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.EquivalentLengths: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
@@ -11,7 +12,7 @@ extension DatabaseClient.EquivalentLengths: TestDependencyKey {
|
||||
.init(
|
||||
create: { request in
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -53,7 +54,7 @@ extension DatabaseClient.EquivalentLengths: TestDependencyKey {
|
||||
}
|
||||
try model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -64,7 +65,9 @@ extension DatabaseClient.EquivalentLengths: TestDependencyKey {
|
||||
extension EquivalentLength.Create {
|
||||
|
||||
func toModel() throws -> EffectiveLengthModel {
|
||||
try validate()
|
||||
if groups.count > 0 {
|
||||
try [EquivalentLength.FittingGroup].validator().validate(groups)
|
||||
}
|
||||
return try .init(
|
||||
name: name,
|
||||
type: type.rawValue,
|
||||
@@ -73,12 +76,6 @@ extension EquivalentLength.Create {
|
||||
projectID: projectID
|
||||
)
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Effective length name can not be empty.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EquivalentLength {
|
||||
@@ -93,8 +90,8 @@ extension EquivalentLength {
|
||||
.field("type", .string, .required)
|
||||
.field("straightLengths", .array(of: .int))
|
||||
.field("groups", .data)
|
||||
.field("createdAt", .datetime)
|
||||
.field("updatedAt", .datetime)
|
||||
.field("createdAt", .string)
|
||||
.field("updatedAt", .string)
|
||||
.field(
|
||||
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||
)
|
||||
@@ -184,7 +181,51 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
|
||||
self.straightLengths = straightLengths
|
||||
}
|
||||
if let groups = updates.groups {
|
||||
if groups.count > 0 {
|
||||
try [EquivalentLength.FittingGroup].validator().validate(groups)
|
||||
}
|
||||
self.groups = try JSONEncoder().encode(groups)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EffectiveLengthModel: Validatable {
|
||||
|
||||
var body: some Validation<EffectiveLengthModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.name, with: .notEmpty())
|
||||
.errorLabel("Name", inline: true)
|
||||
|
||||
Validator.validate(
|
||||
\.straightLengths,
|
||||
with: [Int].empty().or(
|
||||
ForEachValidator {
|
||||
Int.greaterThan(0)
|
||||
})
|
||||
)
|
||||
.errorLabel("Straight Lengths", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension EquivalentLength.FittingGroup: Validatable {
|
||||
|
||||
public var body: some Validation<Self> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.group) {
|
||||
Int.greaterThanOrEquals(1)
|
||||
Int.lessThanOrEquals(12)
|
||||
}
|
||||
.errorLabel("Group", inline: true)
|
||||
|
||||
Validator.validate(\.letter, with: .regex(matching: "[a-zA-Z]"))
|
||||
.errorLabel("Letter", inline: true)
|
||||
|
||||
Validator.validate(\.value, with: .greaterThan(0))
|
||||
.errorLabel("Value", inline: true)
|
||||
|
||||
Validator.validate(\.quantity, with: .greaterThanOrEquals(1))
|
||||
.errorLabel("Quantity", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ extension DatabaseClient.Migrations: DependencyKey {
|
||||
public static let liveValue = Self(
|
||||
all: {
|
||||
[
|
||||
Project.Migrate(),
|
||||
User.Migrate(),
|
||||
User.Token.Migrate(),
|
||||
User.Profile.Migrate(),
|
||||
Project.Migrate(),
|
||||
ComponentPressureLoss.Migrate(),
|
||||
EquipmentInfo.Migrate(),
|
||||
Room.Migrate(),
|
||||
|
||||
10
Sources/DatabaseClient/Internal/Model+validateAndSave.swift
Normal file
10
Sources/DatabaseClient/Internal/Model+validateAndSave.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import Fluent
|
||||
import Validations
|
||||
|
||||
extension Model where Self: Validations.Validatable {
|
||||
|
||||
func validateAndSave(on database: any Database) async throws {
|
||||
try self.validate()
|
||||
try await self.save(on: database)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.Projects: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
@@ -10,8 +11,8 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { userID, request in
|
||||
let model = try request.toModel(userID: userID)
|
||||
try await model.save(on: database)
|
||||
let model = request.toModel(userID: userID)
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -81,10 +82,9 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
||||
guard let model = try await ProjectModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
try updates.validate()
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -94,8 +94,7 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
||||
|
||||
extension Project.Create {
|
||||
|
||||
func toModel(userID: User.ID) throws -> ProjectModel {
|
||||
try validate()
|
||||
func toModel(userID: User.ID) -> ProjectModel {
|
||||
return .init(
|
||||
name: name,
|
||||
streetAddress: streetAddress,
|
||||
@@ -106,70 +105,6 @@ extension Project.Create {
|
||||
)
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Project name should not be empty.")
|
||||
}
|
||||
guard !streetAddress.isEmpty else {
|
||||
throw ValidationError("Project street address should not be empty.")
|
||||
}
|
||||
guard !city.isEmpty else {
|
||||
throw ValidationError("Project city should not be empty.")
|
||||
}
|
||||
guard !state.isEmpty else {
|
||||
throw ValidationError("Project state should not be empty.")
|
||||
}
|
||||
guard !zipCode.isEmpty else {
|
||||
throw ValidationError("Project zipCode should not be empty.")
|
||||
}
|
||||
if let sensibleHeatRatio {
|
||||
guard sensibleHeatRatio >= 0 else {
|
||||
throw ValidationError("Project sensible heat ratio should be greater than 0.")
|
||||
}
|
||||
guard sensibleHeatRatio <= 1 else {
|
||||
throw ValidationError("Project sensible heat ratio should be less than 1.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Project.Update {
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
if let name {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Project name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let streetAddress {
|
||||
guard !streetAddress.isEmpty else {
|
||||
throw ValidationError("Project street address should not be empty.")
|
||||
}
|
||||
}
|
||||
if let city {
|
||||
guard !city.isEmpty else {
|
||||
throw ValidationError("Project city should not be empty.")
|
||||
}
|
||||
}
|
||||
if let state {
|
||||
guard !state.isEmpty else {
|
||||
throw ValidationError("Project state should not be empty.")
|
||||
}
|
||||
}
|
||||
if let zipCode {
|
||||
guard !zipCode.isEmpty else {
|
||||
throw ValidationError("Project zipCode should not be empty.")
|
||||
}
|
||||
}
|
||||
if let sensibleHeatRatio {
|
||||
guard sensibleHeatRatio >= 0 else {
|
||||
throw ValidationError("Project sensible heat ratio should be greater than 0.")
|
||||
}
|
||||
guard sensibleHeatRatio <= 1 else {
|
||||
throw ValidationError("Project sensible heat ratio should be less than 1.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Project {
|
||||
@@ -185,8 +120,8 @@ extension Project {
|
||||
.field("state", .string, .required)
|
||||
.field("zipCode", .string, .required)
|
||||
.field("sensibleHeatRatio", .double)
|
||||
.field("createdAt", .datetime)
|
||||
.field("updatedAt", .datetime)
|
||||
.field("createdAt", .string)
|
||||
.field("updatedAt", .string)
|
||||
.field("userID", .uuid, .required, .references(UserModel.schema, "id", onDelete: .cascade))
|
||||
.unique(on: "userID", "name")
|
||||
.create()
|
||||
@@ -342,3 +277,35 @@ final class ProjectModel: Model, @unchecked Sendable {
|
||||
return model
|
||||
}
|
||||
}
|
||||
|
||||
extension ProjectModel: Validatable {
|
||||
|
||||
var body: some Validation<ProjectModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.name, with: .notEmpty())
|
||||
.errorLabel("Name", inline: true)
|
||||
|
||||
Validator.validate(\.streetAddress, with: .notEmpty())
|
||||
.errorLabel("Address", inline: true)
|
||||
|
||||
Validator.validate(\.city, with: .notEmpty())
|
||||
.errorLabel("City", inline: true)
|
||||
|
||||
Validator.validate(\.state, with: .notEmpty())
|
||||
.errorLabel("State", inline: true)
|
||||
|
||||
Validator.validate(\.zipCode, with: .notEmpty())
|
||||
.errorLabel("Zip", inline: true)
|
||||
|
||||
Validator.validate(\.sensibleHeatRatio) {
|
||||
Validator {
|
||||
Double.greaterThan(0)
|
||||
Double.lessThanOrEquals(1.0)
|
||||
}
|
||||
.optional()
|
||||
}
|
||||
.errorLabel("Sensible Heat Ratio", inline: true)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,17 +3,75 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.Rooms: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
create: { projectID, request in
|
||||
let model = try request.toModel(projectID: projectID)
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
createMany: { projectID, rooms in
|
||||
try await RoomModel.createMany(projectID: projectID, rooms: rooms, on: database)
|
||||
},
|
||||
createFromCSV: { projectID, rows in
|
||||
|
||||
database.logger.debug("\nCreate From CSV rows: \(rows)\n")
|
||||
|
||||
// Filter out rows that delegate their airflow / load to another room,
|
||||
// these need to be created last.
|
||||
let rowsThatDelegate = rows.filter({
|
||||
$0.delegatedToName != nil && $0.delegatedToName != ""
|
||||
})
|
||||
|
||||
// Filter out the rest of the rooms that don't delegate their airflow / loads.
|
||||
let initialRooms = rows.filter({
|
||||
$0.delegatedToName == nil || $0.delegatedToName == ""
|
||||
})
|
||||
.map(\.createModel)
|
||||
|
||||
database.logger.debug("\nInitial rows: \(initialRooms)\n")
|
||||
|
||||
let initialCreated = try await RoomModel.createMany(
|
||||
projectID: projectID,
|
||||
rooms: initialRooms,
|
||||
on: database
|
||||
)
|
||||
database.logger.debug("\nInitially created rows: \(initialCreated)\n")
|
||||
|
||||
let roomsThatDelegateModels = try rowsThatDelegate.reduce(into: [Room.Create]()) {
|
||||
array, row in
|
||||
database.logger.debug("\n\(row.name), delegating to: \(row.delegatedToName!)\n")
|
||||
guard let created = initialCreated.first(where: { $0.name == row.delegatedToName }) else {
|
||||
database.logger.debug(
|
||||
"\nUnable to find created room with name: \(row.delegatedToName!)\n"
|
||||
)
|
||||
throw NotFoundError()
|
||||
}
|
||||
array.append(
|
||||
Room.Create.init(
|
||||
name: row.name,
|
||||
level: row.level,
|
||||
heatingLoad: row.heatingLoad,
|
||||
coolingTotal: row.coolingTotal,
|
||||
coolingSensible: row.coolingSensible,
|
||||
registerCount: 0,
|
||||
delegatedTo: created.id
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return try await RoomModel.createMany(
|
||||
projectID: projectID,
|
||||
rooms: roomsThatDelegateModels,
|
||||
on: database
|
||||
) + initialCreated
|
||||
|
||||
},
|
||||
delete: { id in
|
||||
guard let model = try await RoomModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
@@ -27,8 +85,11 @@ extension DatabaseClient.Rooms: TestDependencyKey {
|
||||
model.rectangularSizes?.removeAll {
|
||||
$0.id == rectangularDuctID
|
||||
}
|
||||
if model.rectangularSizes?.count == 0 {
|
||||
model.rectangularSizes = nil
|
||||
}
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
},
|
||||
@@ -47,11 +108,9 @@ extension DatabaseClient.Rooms: TestDependencyKey {
|
||||
guard let model = try await RoomModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
|
||||
try updates.validate()
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
},
|
||||
@@ -72,69 +131,55 @@ extension DatabaseClient.Rooms: TestDependencyKey {
|
||||
}
|
||||
}
|
||||
|
||||
extension Room.Create {
|
||||
|
||||
func toModel() throws(ValidationError) -> RoomModel {
|
||||
try validate()
|
||||
extension Room.CSV.Row {
|
||||
fileprivate var createModel: Room.Create {
|
||||
assert(delegatedToName == nil || delegatedToName == "")
|
||||
return .init(
|
||||
name: name,
|
||||
level: level,
|
||||
heatingLoad: heatingLoad,
|
||||
coolingTotal: coolingTotal,
|
||||
coolingSensible: coolingSensible,
|
||||
registerCount: registerCount,
|
||||
projectID: projectID
|
||||
delegatedTo: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Room name should not be empty.")
|
||||
}
|
||||
guard heatingLoad >= 0 else {
|
||||
throw ValidationError("Room heating load should not be less than 0.")
|
||||
}
|
||||
guard coolingTotal >= 0 else {
|
||||
throw ValidationError("Room cooling total should not be less than 0.")
|
||||
}
|
||||
if let coolingSensible {
|
||||
guard coolingSensible >= 0 else {
|
||||
throw ValidationError("Room cooling sensible should not be less than 0.")
|
||||
}
|
||||
}
|
||||
guard registerCount >= 1 else {
|
||||
throw ValidationError("Room cooling sensible should not be less than 1.")
|
||||
extension RoomModel {
|
||||
fileprivate static func createMany(
|
||||
projectID: Project.ID,
|
||||
rooms: [Room.Create],
|
||||
on database: any Database
|
||||
) async throws -> [Room] {
|
||||
try await rooms.asyncMap { request in
|
||||
let model = try request.toModel(projectID: projectID)
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Room.Update {
|
||||
extension Room.Create {
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
if let name {
|
||||
guard !name.isEmpty else {
|
||||
throw ValidationError("Room name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let heatingLoad {
|
||||
guard heatingLoad >= 0 else {
|
||||
throw ValidationError("Room heating load should not be less than 0.")
|
||||
}
|
||||
}
|
||||
if let coolingTotal {
|
||||
guard coolingTotal >= 0 else {
|
||||
throw ValidationError("Room cooling total should not be less than 0.")
|
||||
}
|
||||
}
|
||||
if let coolingSensible {
|
||||
guard coolingSensible >= 0 else {
|
||||
throw ValidationError("Room cooling sensible should not be less than 0.")
|
||||
}
|
||||
}
|
||||
if let registerCount {
|
||||
guard registerCount >= 1 else {
|
||||
throw ValidationError("Room cooling sensible should not be less than 1.")
|
||||
}
|
||||
func toModel(projectID: Project.ID) throws -> RoomModel {
|
||||
var registerCount = registerCount
|
||||
// Set register count appropriately when delegatedTo is set / changes.
|
||||
if delegatedTo != nil {
|
||||
registerCount = 0
|
||||
} else if registerCount == 0 {
|
||||
registerCount = 1
|
||||
}
|
||||
|
||||
return .init(
|
||||
name: name,
|
||||
level: level?.rawValue,
|
||||
heatingLoad: heatingLoad,
|
||||
coolingLoad: coolingLoad,
|
||||
registerCount: registerCount,
|
||||
delegetedToID: delegatedTo,
|
||||
projectID: projectID
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,13 +191,14 @@ extension Room {
|
||||
try await database.schema(RoomModel.schema)
|
||||
.id()
|
||||
.field("name", .string, .required)
|
||||
.field("level", .int8)
|
||||
.field("heatingLoad", .double, .required)
|
||||
.field("coolingTotal", .double, .required)
|
||||
.field("coolingSensible", .double)
|
||||
.field("coolingLoad", .dictionary, .required)
|
||||
.field("registerCount", .int8, .required)
|
||||
.field("delegatedToID", .uuid, .references(RoomModel.schema, "id"))
|
||||
.field("rectangularSizes", .array)
|
||||
.field("createdAt", .datetime)
|
||||
.field("updatedAt", .datetime)
|
||||
.field("createdAt", .string)
|
||||
.field("updatedAt", .string)
|
||||
.field(
|
||||
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||
)
|
||||
@@ -166,7 +212,7 @@ extension Room {
|
||||
}
|
||||
}
|
||||
|
||||
final class RoomModel: Model, @unchecked Sendable {
|
||||
final class RoomModel: Model, @unchecked Sendable, Validatable {
|
||||
|
||||
static let schema = "room"
|
||||
|
||||
@@ -176,18 +222,21 @@ final class RoomModel: Model, @unchecked Sendable {
|
||||
@Field(key: "name")
|
||||
var name: String
|
||||
|
||||
@Field(key: "level")
|
||||
var level: Int?
|
||||
|
||||
@Field(key: "heatingLoad")
|
||||
var heatingLoad: Double
|
||||
|
||||
@Field(key: "coolingTotal")
|
||||
var coolingTotal: Double
|
||||
|
||||
@Field(key: "coolingSensible")
|
||||
var coolingSensible: Double?
|
||||
@Field(key: "coolingLoad")
|
||||
var coolingLoad: Room.CoolingLoad
|
||||
|
||||
@Field(key: "registerCount")
|
||||
var registerCount: Int
|
||||
|
||||
@OptionalParent(key: "delegatedToID")
|
||||
var room: RoomModel?
|
||||
|
||||
@Field(key: "rectangularSizes")
|
||||
var rectangularSizes: [Room.RectangularSize]?
|
||||
|
||||
@@ -205,10 +254,11 @@ final class RoomModel: Model, @unchecked Sendable {
|
||||
init(
|
||||
id: UUID? = nil,
|
||||
name: String,
|
||||
level: Int? = nil,
|
||||
heatingLoad: Double,
|
||||
coolingTotal: Double,
|
||||
coolingSensible: Double? = nil,
|
||||
coolingLoad: Room.CoolingLoad,
|
||||
registerCount: Int,
|
||||
delegetedToID: UUID? = nil,
|
||||
rectangularSizes: [Room.RectangularSize]? = nil,
|
||||
createdAt: Date? = nil,
|
||||
updatedAt: Date? = nil,
|
||||
@@ -216,10 +266,11 @@ final class RoomModel: Model, @unchecked Sendable {
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.level = level
|
||||
self.heatingLoad = heatingLoad
|
||||
self.coolingTotal = coolingTotal
|
||||
self.coolingSensible = coolingSensible
|
||||
self.coolingLoad = coolingLoad
|
||||
self.registerCount = registerCount
|
||||
$room.id = delegetedToID
|
||||
self.rectangularSizes = rectangularSizes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
@@ -231,10 +282,11 @@ final class RoomModel: Model, @unchecked Sendable {
|
||||
id: requireID(),
|
||||
projectID: $project.id,
|
||||
name: name,
|
||||
level: level.map(Room.Level.init(rawValue:)),
|
||||
heatingLoad: heatingLoad,
|
||||
coolingTotal: coolingTotal,
|
||||
coolingSensible: coolingSensible,
|
||||
coolingLoad: coolingLoad,
|
||||
registerCount: registerCount,
|
||||
delegatedTo: $room.id,
|
||||
rectangularSizes: rectangularSizes,
|
||||
createdAt: createdAt!,
|
||||
updatedAt: updatedAt!
|
||||
@@ -246,14 +298,14 @@ final class RoomModel: Model, @unchecked Sendable {
|
||||
if let name = updates.name, name != self.name {
|
||||
self.name = name
|
||||
}
|
||||
if let level = updates.level?.rawValue, level != self.level {
|
||||
self.level = level
|
||||
}
|
||||
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
|
||||
self.heatingLoad = heatingLoad
|
||||
}
|
||||
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
|
||||
self.coolingTotal = coolingTotal
|
||||
}
|
||||
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
|
||||
self.coolingSensible = coolingSensible
|
||||
if let coolingLoad = updates.coolingLoad, coolingLoad != self.coolingLoad {
|
||||
self.coolingLoad = coolingLoad
|
||||
}
|
||||
if let registerCount = updates.registerCount, registerCount != self.registerCount {
|
||||
self.registerCount = registerCount
|
||||
@@ -264,4 +316,81 @@ final class RoomModel: Model, @unchecked Sendable {
|
||||
|
||||
}
|
||||
|
||||
var body: some Validation<RoomModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.name, with: .notEmpty())
|
||||
.errorLabel("Name", inline: true)
|
||||
|
||||
Validator.validate(\.heatingLoad, with: .greaterThanOrEquals(0))
|
||||
.errorLabel("Heating Load", inline: true)
|
||||
|
||||
Validator.validate(\.coolingLoad)
|
||||
.errorLabel("Cooling Load", inline: true)
|
||||
|
||||
Validator.validate(\.registerCount, with: .greaterThanOrEquals($room.id == nil ? 1 : 0))
|
||||
.errorLabel("Register Count", inline: true)
|
||||
|
||||
Validator.validate(\.rectangularSizes)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func validateAndSave(on database: Database) async throws {
|
||||
try self.validate()
|
||||
if let delegateTo = $room.id {
|
||||
guard
|
||||
let parent =
|
||||
try await RoomModel
|
||||
.query(on: database)
|
||||
.with(\.$room)
|
||||
.filter(\.$id == delegateTo)
|
||||
.first()
|
||||
else {
|
||||
throw ValidationError("Can not find room: \(delegateTo), to delegate airflow to.")
|
||||
}
|
||||
guard parent.$room.id == nil else {
|
||||
throw ValidationError(
|
||||
"""
|
||||
Attempting to delegate to: \(parent.name), that delegates to: \(parent.$room.name)
|
||||
|
||||
Unable to delegate airflow to a room that already delegates it's airflow.
|
||||
"""
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
try await save(on: database)
|
||||
}
|
||||
}
|
||||
|
||||
extension Room.CoolingLoad: Validatable {
|
||||
|
||||
public var body: some Validation<Self> {
|
||||
Validator.accumulating {
|
||||
// Ensure that at least one of the values is not nil.
|
||||
Validator.oneOf {
|
||||
Validator.validate(\.total, with: .notNil())
|
||||
.errorLabel("Total or Sensible", inline: true)
|
||||
Validator.validate(\.sensible, with: .notNil())
|
||||
.errorLabel("Total or Sensible", inline: true)
|
||||
}
|
||||
|
||||
Validator.validate(\.total, with: Double.greaterThan(0).optional())
|
||||
Validator.validate(\.sensible, with: Double.greaterThan(0).optional())
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Room.RectangularSize: Validatable {
|
||||
|
||||
public var body: some Validation<Self> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.register, with: Int.greaterThanOrEquals(1).optional())
|
||||
.errorLabel("Register", inline: true)
|
||||
|
||||
Validator.validate(\.height, with: Int.greaterThanOrEquals(1))
|
||||
.errorLabel("Height", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
41
Sources/DatabaseClient/Internal/Sequence+asyncMap.swift
Normal file
41
Sources/DatabaseClient/Internal/Sequence+asyncMap.swift
Normal file
@@ -0,0 +1,41 @@
|
||||
import Foundation
|
||||
|
||||
extension Sequence {
|
||||
|
||||
// Taken from: https://forums.swift.org/t/are-there-any-kind-of-asyncmap-method-for-a-normal-sequence/77354/7
|
||||
func asyncMap<Result: Sendable>(
|
||||
_ transform: @escaping @Sendable (Element) async throws -> Result
|
||||
) async rethrows -> [Result] where Element: Sendable {
|
||||
try await withThrowingTaskGroup(of: (Int, Result).self) { group in
|
||||
|
||||
var i = 0
|
||||
var iterator = self.makeIterator()
|
||||
var results = [Result?]()
|
||||
results.reserveCapacity(underestimatedCount)
|
||||
|
||||
func submitTask() throws {
|
||||
try Task.checkCancellation()
|
||||
if let element = iterator.next() {
|
||||
results.append(nil)
|
||||
group.addTask { [i] in try await (i, transform(element)) }
|
||||
i += 1
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Add initial tasks
|
||||
for _ in 0..<ProcessInfo.processInfo.processorCount {
|
||||
try submitTask()
|
||||
}
|
||||
|
||||
// Submit more tasks as results complete
|
||||
while let (index, result) = try await group.next() {
|
||||
results[index] = result
|
||||
try submitTask()
|
||||
}
|
||||
|
||||
return results.compactMap { $0 }
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
@@ -10,12 +11,12 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
try request.validate()
|
||||
// try request.validate()
|
||||
|
||||
let trunk = request.toModel()
|
||||
var roomProxies = [TrunkSize.RoomProxy]()
|
||||
|
||||
try await trunk.save(on: database)
|
||||
try await trunk.validateAndSave(on: database)
|
||||
|
||||
for (roomID, registers) in request.rooms {
|
||||
guard let room = try await RoomModel.find(roomID, on: database) else {
|
||||
@@ -27,7 +28,7 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
registers: registers,
|
||||
type: request.type
|
||||
)
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
roomProxies.append(
|
||||
.init(room: try room.toDTO(), registers: registers)
|
||||
)
|
||||
@@ -37,7 +38,9 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
id: trunk.requireID(),
|
||||
projectID: trunk.$project.id,
|
||||
type: .init(rawValue: trunk.type)!,
|
||||
rooms: roomProxies
|
||||
rooms: roomProxies,
|
||||
height: trunk.height,
|
||||
name: trunk.name
|
||||
)
|
||||
},
|
||||
delete: { id in
|
||||
@@ -78,7 +81,7 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
try updates.validate()
|
||||
// try updates.validate()
|
||||
try await model.applyUpdates(updates, on: database)
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -88,17 +91,6 @@ extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||
|
||||
extension TrunkSize.Create {
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard rooms.count > 0 else {
|
||||
throw ValidationError("Trunk size should have associated rooms / registers.")
|
||||
}
|
||||
if let height {
|
||||
guard height > 0 else {
|
||||
throw ValidationError("Trunk size height should be greater than 0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toModel() -> TrunkModel {
|
||||
.init(
|
||||
projectID: projectID,
|
||||
@@ -109,21 +101,6 @@ extension TrunkSize.Create {
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkSize.Update {
|
||||
func validate() throws(ValidationError) {
|
||||
if let rooms {
|
||||
guard rooms.count > 0 else {
|
||||
throw ValidationError("Trunk size should have associated rooms / registers.")
|
||||
}
|
||||
}
|
||||
if let height {
|
||||
guard height > 0 else {
|
||||
throw ValidationError("Trunk size height should be greater than 0.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkSize {
|
||||
|
||||
struct Migrate: AsyncMigration {
|
||||
@@ -198,15 +175,22 @@ final class TrunkRoomModel: Model, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func toDTO() throws -> TrunkSize.RoomProxy {
|
||||
// guard let room = try await RoomModel.find($room.id, on: database) else {
|
||||
// throw NotFoundError()
|
||||
// }
|
||||
return .init(
|
||||
room: try room.toDTO(),
|
||||
registers: registers
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkRoomModel: Validatable {
|
||||
var body: some Validation<TrunkRoomModel> {
|
||||
Validator.validate(\.registers) {
|
||||
[Int].notEmpty()
|
||||
ForEachValidator {
|
||||
Int.greaterThanOrEquals(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class TrunkModel: Model, @unchecked Sendable {
|
||||
@@ -248,19 +232,6 @@ final class TrunkModel: Model, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func toDTO() throws -> TrunkSize {
|
||||
// let rooms = try await withThrowingTaskGroup(of: TrunkSize.RoomProxy.self) { group in
|
||||
// for room in self.rooms {
|
||||
// group.addTask {
|
||||
// try await room.toDTO(on: database)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return try await group.reduce(into: [TrunkSize.RoomProxy]()) {
|
||||
// $0.append($1)
|
||||
// }
|
||||
//
|
||||
// }
|
||||
|
||||
let rooms = try rooms.reduce(into: [TrunkSize.RoomProxy]()) {
|
||||
$0.append(try $1.toDTO())
|
||||
}
|
||||
@@ -290,7 +261,7 @@ final class TrunkModel: Model, @unchecked Sendable {
|
||||
self.name = name
|
||||
}
|
||||
if hasChanges {
|
||||
try await self.save(on: database)
|
||||
try await self.validateAndSave(on: database)
|
||||
}
|
||||
|
||||
guard let updateRooms = updates.rooms else {
|
||||
@@ -311,7 +282,7 @@ final class TrunkModel: Model, @unchecked Sendable {
|
||||
currRoom.registers = registers
|
||||
}
|
||||
if currRoom.hasChanges {
|
||||
try await currRoom.save(on: database)
|
||||
try await currRoom.validateAndSave(on: database)
|
||||
}
|
||||
} else {
|
||||
database.logger.debug("CREATING NEW TrunkRoomModel")
|
||||
@@ -338,19 +309,27 @@ final class TrunkModel: Model, @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkModel: Validatable {
|
||||
|
||||
var body: some Validation<TrunkModel> {
|
||||
Validator.accumulating {
|
||||
|
||||
Validator.validate(\.height, with: Int.greaterThan(0).optional())
|
||||
.errorLabel("Height", inline: true)
|
||||
|
||||
Validator.validate(\.name, with: String.notEmpty().optional())
|
||||
.errorLabel("Name", inline: true)
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == TrunkModel {
|
||||
|
||||
func toDTO() throws -> [TrunkSize] {
|
||||
// try await withThrowingTaskGroup(of: TrunkSize.self) { group in
|
||||
// for model in self {
|
||||
// group.addTask {
|
||||
// try await model.toDTO(on: database)
|
||||
// }
|
||||
// }
|
||||
|
||||
return try reduce(into: [TrunkSize]()) {
|
||||
$0.append(try $1.toDTO())
|
||||
}
|
||||
}
|
||||
// }
|
||||
}
|
||||
|
||||
20
Sources/DatabaseClient/Internal/User+validation.swift
Normal file
20
Sources/DatabaseClient/Internal/User+validation.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import ManualDCore
|
||||
import Validations
|
||||
|
||||
// Declaring this in seperate file because some Vapor imports
|
||||
// have same name's and this was easiest solution.
|
||||
extension User.Create: Validatable {
|
||||
public var body: some Validation<Self> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.email, with: .email())
|
||||
.errorLabel("Email", inline: true)
|
||||
|
||||
Validator.validate(\.password.count, with: .greaterThanOrEquals(8))
|
||||
.errorLabel("Password Count", inline: true)
|
||||
|
||||
Validator.validate(\.confirmPassword, with: .equals(password))
|
||||
.mapError(ValidationError("Confirm password does not match."))
|
||||
.errorLabel("Confirm Password", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Vapor
|
||||
import Validations
|
||||
|
||||
extension DatabaseClient.UserProfiles: TestDependencyKey {
|
||||
|
||||
@@ -11,9 +12,8 @@ extension DatabaseClient.UserProfiles: TestDependencyKey {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { profile in
|
||||
try profile.validate()
|
||||
let model = profile.toModel()
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
return try model.toDTO()
|
||||
},
|
||||
delete: { id in
|
||||
@@ -37,10 +37,9 @@ extension DatabaseClient.UserProfiles: TestDependencyKey {
|
||||
guard let model = try await UserProfileModel.find(id, on: database) else {
|
||||
throw NotFoundError()
|
||||
}
|
||||
try updates.validate()
|
||||
model.applyUpdates(updates)
|
||||
if model.hasChanges {
|
||||
try await model.save(on: database)
|
||||
try await model.validateAndSave(on: database)
|
||||
}
|
||||
return try model.toDTO()
|
||||
}
|
||||
@@ -50,30 +49,6 @@ extension DatabaseClient.UserProfiles: TestDependencyKey {
|
||||
|
||||
extension User.Profile.Create {
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
guard !firstName.isEmpty else {
|
||||
throw ValidationError("User first name should not be empty.")
|
||||
}
|
||||
guard !lastName.isEmpty else {
|
||||
throw ValidationError("User last name should not be empty.")
|
||||
}
|
||||
guard !companyName.isEmpty else {
|
||||
throw ValidationError("User company name should not be empty.")
|
||||
}
|
||||
guard !streetAddress.isEmpty else {
|
||||
throw ValidationError("User street address should not be empty.")
|
||||
}
|
||||
guard !city.isEmpty else {
|
||||
throw ValidationError("User city should not be empty.")
|
||||
}
|
||||
guard !state.isEmpty else {
|
||||
throw ValidationError("User state should not be empty.")
|
||||
}
|
||||
guard !zipCode.isEmpty else {
|
||||
throw ValidationError("User zip code should not be empty.")
|
||||
}
|
||||
}
|
||||
|
||||
func toModel() -> UserProfileModel {
|
||||
.init(
|
||||
userID: userID,
|
||||
@@ -89,47 +64,6 @@ extension User.Profile.Create {
|
||||
}
|
||||
}
|
||||
|
||||
extension User.Profile.Update {
|
||||
|
||||
func validate() throws(ValidationError) {
|
||||
if let firstName {
|
||||
guard !firstName.isEmpty else {
|
||||
throw ValidationError("User first name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let lastName {
|
||||
guard !lastName.isEmpty else {
|
||||
throw ValidationError("User last name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let companyName {
|
||||
guard !companyName.isEmpty else {
|
||||
throw ValidationError("User company name should not be empty.")
|
||||
}
|
||||
}
|
||||
if let streetAddress {
|
||||
guard !streetAddress.isEmpty else {
|
||||
throw ValidationError("User street address should not be empty.")
|
||||
}
|
||||
}
|
||||
if let city {
|
||||
guard !city.isEmpty else {
|
||||
throw ValidationError("User city should not be empty.")
|
||||
}
|
||||
}
|
||||
if let state {
|
||||
guard !state.isEmpty else {
|
||||
throw ValidationError("User state should not be empty.")
|
||||
}
|
||||
}
|
||||
if let zipCode {
|
||||
guard !zipCode.isEmpty else {
|
||||
throw ValidationError("User zip code should not be empty.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension User.Profile {
|
||||
|
||||
struct Migrate: AsyncMigration {
|
||||
@@ -147,8 +81,8 @@ extension User.Profile {
|
||||
.field("zipCode", .string, .required)
|
||||
.field("theme", .string)
|
||||
.field("userID", .uuid, .references(UserModel.schema, "id", onDelete: .cascade))
|
||||
.field("createdAt", .datetime)
|
||||
.field("updatedAt", .datetime)
|
||||
.field("createdAt", .string)
|
||||
.field("updatedAt", .string)
|
||||
.unique(on: "userID")
|
||||
.create()
|
||||
}
|
||||
@@ -270,3 +204,31 @@ final class UserProfileModel: Model, @unchecked Sendable {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension UserProfileModel: Validatable {
|
||||
|
||||
var body: some Validation<UserProfileModel> {
|
||||
Validator.accumulating {
|
||||
Validator.validate(\.firstName, with: .notEmpty())
|
||||
.errorLabel("First Name", inline: true)
|
||||
|
||||
Validator.validate(\.lastName, with: .notEmpty())
|
||||
.errorLabel("Last Name", inline: true)
|
||||
|
||||
Validator.validate(\.companyName, with: .notEmpty())
|
||||
.errorLabel("Company", inline: true)
|
||||
|
||||
Validator.validate(\.streetAddress, with: .notEmpty())
|
||||
.errorLabel("Address", inline: true)
|
||||
|
||||
Validator.validate(\.city, with: .notEmpty())
|
||||
.errorLabel("City", inline: true)
|
||||
|
||||
Validator.validate(\.state, with: .notEmpty())
|
||||
.errorLabel("State", inline: true)
|
||||
|
||||
Validator.validate(\.zipCode, with: .notEmpty())
|
||||
.errorLabel("Zip", inline: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Fluent
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Vapor
|
||||
|
||||
@@ -10,6 +11,7 @@ extension DatabaseClient.Users: TestDependencyKey {
|
||||
public static func live(database: any Database) -> Self {
|
||||
.init(
|
||||
create: { request in
|
||||
try request.validate()
|
||||
let model = try request.toModel()
|
||||
try await model.save(on: database)
|
||||
return try model.toDTO()
|
||||
@@ -74,8 +76,8 @@ extension User {
|
||||
.id()
|
||||
.field("email", .string, .required)
|
||||
.field("password_hash", .string, .required)
|
||||
.field("createdAt", .datetime)
|
||||
.field("updatedAt", .datetime)
|
||||
.field("createdAt", .string)
|
||||
.field("updatedAt", .string)
|
||||
.unique(on: "email")
|
||||
.create()
|
||||
}
|
||||
@@ -95,8 +97,8 @@ extension User.Token {
|
||||
.id()
|
||||
.field("value", .string, .required)
|
||||
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
|
||||
.field("createdAt", .datetime)
|
||||
.field("updatedAt", .datetime)
|
||||
.field("createdAt", .string)
|
||||
.field("updatedAt", .string)
|
||||
.unique(on: "value")
|
||||
.create()
|
||||
}
|
||||
@@ -118,21 +120,8 @@ extension User {
|
||||
extension User.Create {
|
||||
|
||||
func toModel() throws -> UserModel {
|
||||
try validate()
|
||||
return try .init(email: email, passwordHash: User.hashPassword(password))
|
||||
}
|
||||
|
||||
func validate() throws {
|
||||
guard !email.isEmpty else {
|
||||
throw ValidationError("Email should not be empty")
|
||||
}
|
||||
guard password.count > 8 else {
|
||||
throw ValidationError("Password should be more than 8 characters long.")
|
||||
}
|
||||
guard password == confirmPassword else {
|
||||
throw ValidationError("Passwords do not match.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class UserModel: Model, @unchecked Sendable {
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
|
||||
extension DependencyValues {
|
||||
|
||||
/// Holds values defined in the process environment that are needed.
|
||||
///
|
||||
/// These are generally loaded from a `.env` file, but also have default values,
|
||||
/// if not found.
|
||||
public var env: @Sendable () throws -> EnvVars {
|
||||
get { self[EnvClient.self].env }
|
||||
set { self[EnvClient.self].env = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
@DependencyClient
|
||||
struct EnvClient: Sendable {
|
||||
public var env: @Sendable () throws -> EnvVars
|
||||
}
|
||||
|
||||
/// Holds values defined in the process environment that are needed.
|
||||
///
|
||||
/// These are generally loaded from a `.env` file, but also have default values,
|
||||
/// if not found.
|
||||
public struct EnvVars: Codable, Equatable, Sendable {
|
||||
|
||||
/// The path to the pandoc executable on the system, used to generate pdf's.
|
||||
public let pandocPath: String
|
||||
|
||||
/// The pdf engine to use with pandoc when creating pdf's.
|
||||
public let pdfEngine: String
|
||||
|
||||
public init(
|
||||
pandocPath: String = "/usr/bin/pandoc",
|
||||
pdfEngine: String = "weasyprint"
|
||||
) {
|
||||
self.pandocPath = pandocPath
|
||||
self.pdfEngine = pdfEngine
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pandocPath = "PANDOC_PATH"
|
||||
case pdfEngine = "PDF_ENGINE"
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension EnvClient: DependencyKey {
|
||||
static let testValue = Self()
|
||||
|
||||
static let liveValue = Self(env: {
|
||||
// Convert default values into a dictionary.
|
||||
let defaults =
|
||||
(try? encoder.encode(EnvVars()))
|
||||
.flatMap { try? decoder.decode([String: String].self, from: $0) }
|
||||
?? [:]
|
||||
|
||||
// Merge the default values with values found in process environment.
|
||||
let assigned = defaults.merging(ProcessInfo.processInfo.environment, uniquingKeysWith: { $1 })
|
||||
|
||||
return (try? JSONSerialization.data(withJSONObject: assigned))
|
||||
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
|
||||
?? .init()
|
||||
})
|
||||
}
|
||||
|
||||
private let encoder: JSONEncoder = {
|
||||
JSONEncoder()
|
||||
}()
|
||||
|
||||
private let decoder: JSONDecoder = {
|
||||
JSONDecoder()
|
||||
}()
|
||||
100
Sources/EnvVars/Interface.swift
Normal file
100
Sources/EnvVars/Interface.swift
Normal file
@@ -0,0 +1,100 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Foundation
|
||||
|
||||
extension DependencyValues {
|
||||
|
||||
/// Holds values defined in the process environment that are needed.
|
||||
///
|
||||
/// These are generally loaded from a `.env` file, but also have default values,
|
||||
/// if not found.
|
||||
public var environment: EnvVars {
|
||||
get { self[EnvVars.self] }
|
||||
set { self[EnvVars.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds values defined in the process environment that are needed.
|
||||
///
|
||||
/// These are generally loaded from a `.env` file, but also have default values,
|
||||
/// if not found.
|
||||
public struct EnvVars: Codable, Equatable, Sendable {
|
||||
|
||||
/// The path to the pandoc executable on the system, used to generate pdf's.
|
||||
public let pandocPath: String
|
||||
|
||||
/// The pdf engine to use with pandoc when creating pdf's.
|
||||
public let pdfEngine: String
|
||||
|
||||
/// The postgres hostname, used for production database connection.
|
||||
public let postgresHostname: String?
|
||||
|
||||
/// The postgres username, used for production database connection.
|
||||
public let postgresUsername: String?
|
||||
|
||||
/// The postgres password, used for production database connection.
|
||||
public let postgresPassword: String?
|
||||
|
||||
/// The postgres database, used for production database connection.
|
||||
public let postgresDatabase: String?
|
||||
|
||||
/// The path to the sqlite database, used for development database connection.
|
||||
public let sqlitePath: String?
|
||||
|
||||
public init(
|
||||
pandocPath: String = "/usr/bin/pandoc",
|
||||
pdfEngine: String = "weasyprint",
|
||||
postgresHostname: String? = "localhost",
|
||||
postgresUsername: String? = "vapor",
|
||||
postgresPassword: String? = "super-secret",
|
||||
postgresDatabase: String? = "vapor",
|
||||
sqlitePath: String? = "db.sqlite"
|
||||
) {
|
||||
self.pandocPath = pandocPath
|
||||
self.pdfEngine = pdfEngine
|
||||
self.postgresHostname = postgresHostname
|
||||
self.postgresUsername = postgresUsername
|
||||
self.postgresPassword = postgresPassword
|
||||
self.postgresDatabase = postgresDatabase
|
||||
self.sqlitePath = sqlitePath
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case pandocPath = "PANDOC_PATH"
|
||||
case pdfEngine = "PDF_ENGINE"
|
||||
case postgresHostname = "POSTGRES_HOSTNAME"
|
||||
case postgresUsername = "POSTGRES_USER"
|
||||
case postgresPassword = "POSTGRES_PASSWORD"
|
||||
case postgresDatabase = "POSTGRES_DB"
|
||||
case sqlitePath = "SQLITE_PATH"
|
||||
}
|
||||
|
||||
public static func live(_ env: [String: String] = ProcessInfo.processInfo.environment) -> Self {
|
||||
|
||||
// Convert default values into a dictionary.
|
||||
let defaults =
|
||||
(try? encoder.encode(EnvVars()))
|
||||
.flatMap { try? decoder.decode([String: String].self, from: $0) }
|
||||
?? [:]
|
||||
|
||||
// Merge the default values with values found in process environment.
|
||||
let assigned = defaults.merging(env, uniquingKeysWith: { $1 })
|
||||
|
||||
return (try? JSONSerialization.data(withJSONObject: assigned))
|
||||
.flatMap { try? decoder.decode(EnvVars.self, from: $0) }
|
||||
?? .init()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension EnvVars: TestDependencyKey {
|
||||
public static let testValue = Self()
|
||||
}
|
||||
|
||||
private let encoder: JSONEncoder = {
|
||||
JSONEncoder()
|
||||
}()
|
||||
|
||||
private let decoder: JSONDecoder = {
|
||||
JSONDecoder()
|
||||
}()
|
||||
@@ -1,51 +1,6 @@
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
extension Room {
|
||||
|
||||
var heatingLoadPerRegister: Double {
|
||||
|
||||
heatingLoad / Double(registerCount)
|
||||
}
|
||||
|
||||
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
|
||||
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
|
||||
return sensible / Double(registerCount)
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkSize.RoomProxy {
|
||||
|
||||
// We need to make sure if registers got removed after a trunk
|
||||
// was already made / saved that we do not include registers that
|
||||
// no longer exist.
|
||||
private var actualRegisterCount: Int {
|
||||
guard registers.count <= room.registerCount else {
|
||||
return room.registerCount
|
||||
}
|
||||
return registers.count
|
||||
}
|
||||
|
||||
var totalHeatingLoad: Double {
|
||||
room.heatingLoadPerRegister * Double(actualRegisterCount)
|
||||
}
|
||||
|
||||
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkSize {
|
||||
|
||||
var totalHeatingLoad: Double {
|
||||
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
|
||||
}
|
||||
|
||||
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
|
||||
}
|
||||
}
|
||||
|
||||
extension Array where Element == EffectiveLengthGroup {
|
||||
var totalEffectiveLength: Int {
|
||||
reduce(0) { $0 + $1.effectiveLength }
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import Foundation
|
||||
// import Foundation
|
||||
|
||||
public struct CoolingLoad: Codable, Equatable, Sendable {
|
||||
public let total: Double
|
||||
public let sensible: Double
|
||||
public var latent: Double { total - sensible }
|
||||
public var shr: Double { sensible / total }
|
||||
|
||||
public init(total: Double, sensible: Double) {
|
||||
self.total = total
|
||||
self.sensible = sensible
|
||||
}
|
||||
}
|
||||
//
|
||||
// public struct CoolingLoad: Codable, Equatable, Sendable {
|
||||
// public let total: Double
|
||||
// public let sensible: Double
|
||||
// public var latent: Double { total - sensible }
|
||||
// public var shr: Double { sensible / total }
|
||||
//
|
||||
// public init(total: Double, sensible: Double) {
|
||||
// self.total = total
|
||||
// self.sensible = sensible
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -152,11 +152,12 @@ extension DuctSizes {
|
||||
public static func mock(
|
||||
equipmentInfo: EquipmentInfo,
|
||||
rooms: [Room],
|
||||
trunks: [TrunkSize]
|
||||
trunks: [TrunkSize],
|
||||
shr: Double
|
||||
) -> Self {
|
||||
|
||||
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||
let totalCoolingLoad = rooms.totalCoolingLoad
|
||||
let totalCoolingLoad = try! rooms.totalCoolingLoad(shr: shr)
|
||||
|
||||
let roomContainers = rooms.reduce(into: [RoomContainer]()) { array, room in
|
||||
array += RoomContainer.mock(
|
||||
@@ -164,7 +165,8 @@ extension DuctSizes {
|
||||
totalHeatingLoad: totalHeatingLoad,
|
||||
totalCoolingLoad: totalCoolingLoad,
|
||||
totalHeatingCFM: Double(equipmentInfo.heatingCFM),
|
||||
totalCoolingCFM: Double(equipmentInfo.coolingCFM)
|
||||
totalCoolingCFM: Double(equipmentInfo.coolingCFM),
|
||||
shr: shr
|
||||
)
|
||||
}
|
||||
|
||||
@@ -187,14 +189,15 @@ extension DuctSizes {
|
||||
totalHeatingLoad: Double,
|
||||
totalCoolingLoad: Double,
|
||||
totalHeatingCFM: Double,
|
||||
totalCoolingCFM: Double
|
||||
totalCoolingCFM: Double,
|
||||
shr: Double
|
||||
) -> [Self] {
|
||||
var retval = [DuctSizes.RoomContainer]()
|
||||
let heatingLoad = room.heatingLoad / Double(room.registerCount)
|
||||
let heatingFraction = heatingLoad / totalHeatingLoad
|
||||
let heatingCFM = totalHeatingCFM * heatingFraction
|
||||
// Not really accurate, but works for mocks.
|
||||
let coolingLoad = room.coolingTotal / Double(room.registerCount)
|
||||
let coolingLoad = (try! room.coolingLoad.ensured(shr: shr).total) / Double(room.registerCount)
|
||||
let coolingFraction = coolingLoad / totalCoolingLoad
|
||||
let coolingCFM = totalCoolingCFM * coolingFraction
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import Tagged
|
||||
|
||||
/// Represents a room in a project.
|
||||
///
|
||||
@@ -7,30 +8,40 @@ import Foundation
|
||||
/// room, the number of registers in the room, and any rectangular
|
||||
/// duct size calculations stored for the room.
|
||||
public struct Room: Codable, Equatable, Identifiable, Sendable {
|
||||
|
||||
/// The unique id of the room.
|
||||
public let id: UUID
|
||||
|
||||
/// The project this room is associated with.
|
||||
public let projectID: Project.ID
|
||||
|
||||
/// A unique name for the room in the project.
|
||||
public let name: String
|
||||
|
||||
/// The level of the home the room is on.
|
||||
public let level: Level?
|
||||
|
||||
/// The heating load required for the room (from Manual-J).
|
||||
public let heatingLoad: Double
|
||||
/// The total cooling load required for the room (from Manual-J).
|
||||
public let coolingTotal: Double
|
||||
/// An optional sensible cooling load for the room.
|
||||
///
|
||||
/// **NOTE:** This is generally not set, but calculated from the project wide
|
||||
/// sensible heat ratio.
|
||||
public let coolingSensible: Double?
|
||||
|
||||
/// The cooling load required for the room (from Manual-J).
|
||||
public let coolingLoad: CoolingLoad
|
||||
|
||||
/// The number of registers for the room.
|
||||
public let registerCount: Int
|
||||
|
||||
/// An optional room that the airflow is delegated to.
|
||||
public let delegatedTo: Room.ID?
|
||||
|
||||
/// The rectangular duct size calculations for a room.
|
||||
///
|
||||
/// **NOTE:** These are optionally set after the round sizes have been calculate
|
||||
/// for a room.
|
||||
public let rectangularSizes: [RectangularSize]?
|
||||
|
||||
/// When the room was created in the database.
|
||||
public let createdAt: Date
|
||||
|
||||
/// When the room was updated in the database.
|
||||
public let updatedAt: Date
|
||||
|
||||
@@ -38,10 +49,11 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
||||
id: UUID,
|
||||
projectID: Project.ID,
|
||||
name: String,
|
||||
level: Level? = nil,
|
||||
heatingLoad: Double,
|
||||
coolingTotal: Double,
|
||||
coolingSensible: Double? = nil,
|
||||
coolingLoad: CoolingLoad,
|
||||
registerCount: Int = 1,
|
||||
delegatedTo: Room.ID? = nil,
|
||||
rectangularSizes: [RectangularSize]? = nil,
|
||||
createdAt: Date,
|
||||
updatedAt: Date
|
||||
@@ -49,49 +61,162 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
||||
self.id = id
|
||||
self.projectID = projectID
|
||||
self.name = name
|
||||
self.level = level
|
||||
self.heatingLoad = heatingLoad
|
||||
self.coolingTotal = coolingTotal
|
||||
self.coolingSensible = coolingSensible
|
||||
self.coolingLoad = coolingLoad
|
||||
self.registerCount = registerCount
|
||||
self.delegatedTo = delegatedTo
|
||||
self.rectangularSizes = rectangularSizes
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Represents the cooling load of a room.
|
||||
///
|
||||
/// Generally only one of the values is provided by a Manual-J room x room
|
||||
/// calculation.
|
||||
///
|
||||
public struct CoolingLoad: Codable, Equatable, Sendable {
|
||||
|
||||
public let total: Double?
|
||||
public let sensible: Double?
|
||||
|
||||
public init(total: Double? = nil, sensible: Double? = nil) {
|
||||
self.total = total
|
||||
self.sensible = sensible
|
||||
}
|
||||
|
||||
/// Calculates the cooling load based on the shr.
|
||||
///
|
||||
/// Generally Manual-J room x room loads provide either the total load or the
|
||||
/// sensible load, so this allows us to calculate whichever is not provided.
|
||||
public func ensured(shr: Double) throws -> (total: Double, sensible: Double) {
|
||||
switch (total, sensible) {
|
||||
case (.none, .none):
|
||||
throw CoolingLoadError("Both the total and sensible loads are nil.")
|
||||
case (.some(let total), .some(let sensible)):
|
||||
return (total, sensible)
|
||||
case (.some(let total), .none):
|
||||
return (total, total * shr)
|
||||
case (.none, .some(let sensible)):
|
||||
return (sensible / shr, sensible)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
public enum LevelTag {}
|
||||
|
||||
public typealias Level = Tagged<LevelTag, Int>
|
||||
|
||||
}
|
||||
|
||||
extension Room {
|
||||
/// Represents the data required to create a new room for a project.
|
||||
public struct Create: Codable, Equatable, Sendable {
|
||||
/// The project this room is associated with.
|
||||
public let projectID: Project.ID
|
||||
/// A unique name for the room in the project.
|
||||
public let name: String
|
||||
|
||||
/// An optional level of the home the room is on.
|
||||
public let level: Room.Level?
|
||||
|
||||
/// The heating load required for the room (from Manual-J).
|
||||
public let heatingLoad: Double
|
||||
|
||||
/// The total cooling load required for the room (from Manual-J).
|
||||
public let coolingTotal: Double
|
||||
public let coolingTotal: Double?
|
||||
|
||||
/// An optional sensible cooling load for the room.
|
||||
public let coolingSensible: Double?
|
||||
|
||||
/// The number of registers for the room.
|
||||
public let registerCount: Int
|
||||
|
||||
/// An optional room that this room delegates it's airflow to.
|
||||
public let delegatedTo: Room.ID?
|
||||
|
||||
public var coolingLoad: Room.CoolingLoad {
|
||||
.init(total: coolingTotal, sensible: coolingSensible)
|
||||
}
|
||||
|
||||
public init(
|
||||
projectID: Project.ID,
|
||||
name: String,
|
||||
level: Room.Level? = nil,
|
||||
heatingLoad: Double,
|
||||
coolingTotal: Double,
|
||||
coolingTotal: Double? = nil,
|
||||
coolingSensible: Double? = nil,
|
||||
registerCount: Int = 1
|
||||
registerCount: Int = 1,
|
||||
delegatedTo: Room.ID? = nil
|
||||
) {
|
||||
self.projectID = projectID
|
||||
self.name = name
|
||||
self.level = level
|
||||
self.heatingLoad = heatingLoad
|
||||
self.coolingTotal = coolingTotal
|
||||
self.coolingSensible = coolingSensible
|
||||
self.registerCount = registerCount
|
||||
self.delegatedTo = delegatedTo
|
||||
}
|
||||
}
|
||||
|
||||
public struct CSV: Equatable, Sendable {
|
||||
public let file: Data
|
||||
|
||||
public init(file: Data) {
|
||||
self.file = file
|
||||
}
|
||||
|
||||
/// Represents a row in a CSV file.
|
||||
///
|
||||
/// This is similar to ``Room.Create``, but since the rooms are not yet
|
||||
/// created, delegating to another room is done via the room's name
|
||||
/// instead of id.
|
||||
///
|
||||
public struct Row: Codable, Equatable, Sendable {
|
||||
|
||||
/// A unique name for the room in the project.
|
||||
public let name: String
|
||||
|
||||
/// An optional level of the home the room is on.
|
||||
public let level: Room.Level?
|
||||
|
||||
/// The heating load required for the room (from Manual-J).
|
||||
public let heatingLoad: Double
|
||||
|
||||
/// The total cooling load required for the room (from Manual-J).
|
||||
public let coolingTotal: Double?
|
||||
|
||||
/// An optional sensible cooling load for the room.
|
||||
public let coolingSensible: Double?
|
||||
|
||||
/// The number of registers for the room.
|
||||
public let registerCount: Int
|
||||
|
||||
/// An optional room that this room delegates it's airflow to.
|
||||
public let delegatedToName: String?
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
level: Room.Level? = nil,
|
||||
heatingLoad: Double,
|
||||
coolingTotal: Double? = nil,
|
||||
coolingSensible: Double? = nil,
|
||||
registerCount: Int,
|
||||
delegatedToName: String? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.level = level
|
||||
self.heatingLoad = heatingLoad
|
||||
self.coolingTotal = coolingTotal
|
||||
self.coolingSensible = coolingSensible
|
||||
self.registerCount = registerCount
|
||||
// Treat empty strings as nil, as they are often empty
|
||||
// when left blank in a CSV file.
|
||||
self.delegatedToName = delegatedToName == "" ? nil : delegatedToName
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Represents a rectangular size calculation that is stored in the
|
||||
/// database for a given room.
|
||||
///
|
||||
@@ -118,10 +243,14 @@ extension Room {
|
||||
|
||||
/// Represents field that can be updated on a room after it's been created in the database.
|
||||
///
|
||||
/// Only fields that are supplied get updated.
|
||||
/// Onlly fields that are supplied get updated.
|
||||
public struct Update: Codable, Equatable, Sendable {
|
||||
/// A unique name for the room in the project.
|
||||
public let name: String?
|
||||
|
||||
/// An optional level of the home the room is on.
|
||||
public let level: Room.Level?
|
||||
|
||||
/// The heating load required for the room (from Manual-J).
|
||||
public let heatingLoad: Double?
|
||||
/// The total cooling load required for the room (from Manual-J).
|
||||
@@ -133,14 +262,23 @@ extension Room {
|
||||
/// The rectangular duct size calculations for a room.
|
||||
public let rectangularSizes: [RectangularSize]?
|
||||
|
||||
public var coolingLoad: CoolingLoad? {
|
||||
guard coolingTotal != nil || coolingSensible != nil else {
|
||||
return nil
|
||||
}
|
||||
return .init(total: coolingTotal, sensible: coolingSensible)
|
||||
}
|
||||
|
||||
public init(
|
||||
name: String? = nil,
|
||||
level: Room.Level? = nil,
|
||||
heatingLoad: Double? = nil,
|
||||
coolingTotal: Double? = nil,
|
||||
coolingSensible: Double? = nil,
|
||||
registerCount: Int? = nil
|
||||
) {
|
||||
self.name = name
|
||||
self.level = level
|
||||
self.heatingLoad = heatingLoad
|
||||
self.coolingTotal = coolingTotal
|
||||
self.coolingSensible = coolingSensible
|
||||
@@ -152,6 +290,7 @@ extension Room {
|
||||
rectangularSizes: [RectangularSize]
|
||||
) {
|
||||
self.name = nil
|
||||
self.level = nil
|
||||
self.heatingLoad = nil
|
||||
self.coolingTotal = nil
|
||||
self.coolingSensible = nil
|
||||
@@ -169,22 +308,40 @@ extension Array where Element == Room {
|
||||
}
|
||||
|
||||
/// The sum of total cooling loads for an array of rooms.
|
||||
public var totalCoolingLoad: Double {
|
||||
reduce(into: 0) { $0 += $1.coolingTotal }
|
||||
public func totalCoolingLoad(shr: Double) throws -> Double {
|
||||
try reduce(into: 0) { $0 += try $1.coolingLoad.ensured(shr: shr).total }
|
||||
}
|
||||
|
||||
/// The sum of sensible cooling loads for an array of rooms.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - shr: The project wide sensible heat ratio.
|
||||
public func totalCoolingSensible(shr: Double) -> Double {
|
||||
reduce(into: 0) {
|
||||
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
|
||||
$0 += sensible
|
||||
public func totalCoolingSensible(shr: Double) throws -> Double {
|
||||
try reduce(into: 0) {
|
||||
// let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
|
||||
$0 += try $1.coolingLoad.ensured(shr: shr).sensible
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct CoolingLoadError: Error, Equatable, Sendable {
|
||||
public let reason: String
|
||||
|
||||
public init(_ reason: String) {
|
||||
self.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
extension Room.Level {
|
||||
/// The label for the level, i.e. 'Basement' or 'Level-1', etc.
|
||||
public var label: String {
|
||||
if rawValue <= 0 {
|
||||
return "Basement"
|
||||
}
|
||||
return "Level-\(rawValue)"
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
||||
extension Room {
|
||||
@@ -199,8 +356,8 @@ extension Array where Element == Room {
|
||||
projectID: projectID,
|
||||
name: "Bed-1",
|
||||
heatingLoad: 3913,
|
||||
coolingTotal: 2472,
|
||||
coolingSensible: nil,
|
||||
coolingLoad: .init(total: 2472),
|
||||
// coolingSensible: nil,
|
||||
registerCount: 1,
|
||||
rectangularSizes: nil,
|
||||
createdAt: now,
|
||||
@@ -211,8 +368,8 @@ extension Array where Element == Room {
|
||||
projectID: projectID,
|
||||
name: "Entry",
|
||||
heatingLoad: 8284,
|
||||
coolingTotal: 2916,
|
||||
coolingSensible: nil,
|
||||
coolingLoad: .init(total: 2916),
|
||||
// coolingSensible: nil,
|
||||
registerCount: 2,
|
||||
rectangularSizes: nil,
|
||||
createdAt: now,
|
||||
@@ -223,8 +380,8 @@ extension Array where Element == Room {
|
||||
projectID: projectID,
|
||||
name: "Family Room",
|
||||
heatingLoad: 9785,
|
||||
coolingTotal: 7446,
|
||||
coolingSensible: nil,
|
||||
coolingLoad: .init(total: 7446),
|
||||
// coolingSensible: nil,
|
||||
registerCount: 3,
|
||||
rectangularSizes: nil,
|
||||
createdAt: now,
|
||||
@@ -235,8 +392,8 @@ extension Array where Element == Room {
|
||||
projectID: projectID,
|
||||
name: "Kitchen",
|
||||
heatingLoad: 4518,
|
||||
coolingTotal: 5096,
|
||||
coolingSensible: nil,
|
||||
coolingLoad: .init(total: 5096),
|
||||
// coolingSensible: nil,
|
||||
registerCount: 2,
|
||||
rectangularSizes: nil,
|
||||
createdAt: now,
|
||||
@@ -247,8 +404,8 @@ extension Array where Element == Room {
|
||||
projectID: projectID,
|
||||
name: "Living Room",
|
||||
heatingLoad: 7553,
|
||||
coolingTotal: 6829,
|
||||
coolingSensible: nil,
|
||||
coolingLoad: .init(total: 6829),
|
||||
// coolingSensible: nil,
|
||||
registerCount: 2,
|
||||
rectangularSizes: nil,
|
||||
createdAt: now,
|
||||
@@ -259,8 +416,8 @@ extension Array where Element == Room {
|
||||
projectID: projectID,
|
||||
name: "Master",
|
||||
heatingLoad: 8202,
|
||||
coolingTotal: 2076,
|
||||
coolingSensible: nil,
|
||||
coolingLoad: .init(total: 2076),
|
||||
// coolingSensible: nil,
|
||||
registerCount: 2,
|
||||
rectangularSizes: nil,
|
||||
createdAt: now,
|
||||
|
||||
@@ -1,270 +0,0 @@
|
||||
import CasePathsCore
|
||||
import Foundation
|
||||
@preconcurrency import URLRouting
|
||||
|
||||
extension SiteRoute {
|
||||
/// Represents api routes.
|
||||
///
|
||||
/// The routes return json as opposed to view routes that return html.
|
||||
public enum Api: Sendable, Equatable {
|
||||
|
||||
case project(Self.ProjectRoute)
|
||||
case room(Self.RoomRoute)
|
||||
case equipment(Self.EquipmentRoute)
|
||||
case componentLoss(Self.ComponentLossRoute)
|
||||
|
||||
public static let rootPath = Path {
|
||||
"api"
|
||||
"v1"
|
||||
}
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.project)) {
|
||||
rootPath
|
||||
ProjectRoute.router
|
||||
}
|
||||
Route(.case(Self.room)) {
|
||||
rootPath
|
||||
RoomRoute.router
|
||||
}
|
||||
Route(.case(Self.equipment)) {
|
||||
rootPath
|
||||
EquipmentRoute.router
|
||||
}
|
||||
Route(.case(Self.componentLoss)) {
|
||||
rootPath
|
||||
ComponentLossRoute.router
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension SiteRoute.Api {
|
||||
public enum ProjectRoute: Sendable, Equatable {
|
||||
case create(Project.Create)
|
||||
case delete(id: Project.ID)
|
||||
case detail(id: Project.ID, route: DetailRoute)
|
||||
case get(id: Project.ID)
|
||||
case index
|
||||
|
||||
static let rootPath = "projects"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.create)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body(.json(Project.Create.self))
|
||||
}
|
||||
Route(.case(Self.delete(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
Project.ID.parser()
|
||||
}
|
||||
Method.delete
|
||||
}
|
||||
Route(.case(Self.get(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
Project.ID.parser()
|
||||
}
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.index)) {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.detail(id:route:))) {
|
||||
Path {
|
||||
rootPath
|
||||
Project.ID.parser()
|
||||
}
|
||||
DetailRoute.router
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.Api.ProjectRoute {
|
||||
public enum DetailRoute: Equatable, Sendable {
|
||||
case index
|
||||
case completedSteps
|
||||
|
||||
static let rootPath = "details"
|
||||
|
||||
static let router = OneOf {
|
||||
Route(.case(Self.index)) {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.completedSteps)) {
|
||||
Path {
|
||||
rootPath
|
||||
"completed"
|
||||
}
|
||||
Method.get
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.Api {
|
||||
|
||||
public enum RoomRoute: Sendable, Equatable {
|
||||
case create(Room.Create)
|
||||
case delete(id: Room.ID)
|
||||
case get(id: Room.ID)
|
||||
|
||||
static let rootPath = "rooms"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.create)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body(.json(Room.Create.self))
|
||||
}
|
||||
Route(.case(Self.delete(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
Room.ID.parser()
|
||||
}
|
||||
Method.delete
|
||||
}
|
||||
Route(.case(Self.get(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
Room.ID.parser()
|
||||
}
|
||||
Method.get
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.Api {
|
||||
|
||||
public enum EquipmentRoute: Sendable, Equatable {
|
||||
case create(EquipmentInfo.Create)
|
||||
case delete(id: EquipmentInfo.ID)
|
||||
case fetch(projectID: Project.ID)
|
||||
case get(id: EquipmentInfo.ID)
|
||||
|
||||
static let rootPath = "equipment"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.create)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body(.json(EquipmentInfo.Create.self))
|
||||
}
|
||||
Route(.case(Self.delete(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
EquipmentInfo.ID.parser()
|
||||
}
|
||||
Method.delete
|
||||
}
|
||||
Route(.case(Self.fetch(projectID:))) {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
Query {
|
||||
Field("projectID") { Project.ID.parser() }
|
||||
}
|
||||
}
|
||||
Route(.case(Self.get(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
EquipmentInfo.ID.parser()
|
||||
}
|
||||
Method.get
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.Api {
|
||||
|
||||
public enum ComponentLossRoute: Sendable, Equatable {
|
||||
case create(ComponentPressureLoss.Create)
|
||||
case delete(id: ComponentPressureLoss.ID)
|
||||
case fetch(projectID: Project.ID)
|
||||
case get(id: ComponentPressureLoss.ID)
|
||||
|
||||
static let rootPath = "componentLoss"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.create)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body(.json(ComponentPressureLoss.Create.self))
|
||||
}
|
||||
Route(.case(Self.delete(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
ComponentPressureLoss.ID.parser()
|
||||
}
|
||||
Method.delete
|
||||
}
|
||||
Route(.case(Self.fetch(projectID:))) {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
Query {
|
||||
Field("projectID") { Project.ID.parser() }
|
||||
}
|
||||
}
|
||||
Route(.case(Self.get(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
ComponentPressureLoss.ID.parser()
|
||||
}
|
||||
Method.get
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.Api {
|
||||
public enum EffectiveLengthRoute: Equatable, Sendable {
|
||||
case create(EquivalentLength.Create)
|
||||
case delete(id: EquivalentLength.ID)
|
||||
case fetch(projectID: Project.ID)
|
||||
case get(id: EquivalentLength.ID)
|
||||
|
||||
static let rootPath = "effectiveLength"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.create)) {
|
||||
Path {
|
||||
rootPath
|
||||
"create"
|
||||
}
|
||||
Method.post
|
||||
Body(.json(EquivalentLength.Create.self))
|
||||
}
|
||||
Route(.case(Self.delete(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
EquivalentLength.ID.parser()
|
||||
}
|
||||
Method.delete
|
||||
}
|
||||
Route(.case(Self.fetch(projectID:))) {
|
||||
Path {
|
||||
rootPath
|
||||
}
|
||||
Method.get
|
||||
Query {
|
||||
Field("projectID") { Project.ID.parser() }
|
||||
}
|
||||
}
|
||||
Route(.case(Self.get(id:))) {
|
||||
Path {
|
||||
rootPath
|
||||
EquivalentLength.ID.parser()
|
||||
}
|
||||
Method.get
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,10 @@ import Foundation
|
||||
|
||||
public enum SiteRoute: Equatable, Sendable {
|
||||
|
||||
case api(Self.Api)
|
||||
case health
|
||||
case view(Self.View)
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.api)) {
|
||||
SiteRoute.Api.router
|
||||
}
|
||||
Route(.case(Self.health)) {
|
||||
Path { "health" }
|
||||
Method.get
|
||||
|
||||
@@ -8,9 +8,12 @@ extension SiteRoute {
|
||||
///
|
||||
/// The routes return html.
|
||||
public enum View: Equatable, Sendable {
|
||||
case home
|
||||
case privacyPolicy
|
||||
case login(LoginRoute)
|
||||
case signup(SignupRoute)
|
||||
case project(ProjectRoute)
|
||||
case ductulator(DuctulatorRoute)
|
||||
case user(UserRoute)
|
||||
//FIX: Remove.
|
||||
case test
|
||||
@@ -20,6 +23,13 @@ extension SiteRoute {
|
||||
Path { "test" }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.home)) {
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.privacyPolicy)) {
|
||||
Path { "privacy-policy" }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.login)) {
|
||||
SiteRoute.View.LoginRoute.router
|
||||
}
|
||||
@@ -29,6 +39,9 @@ extension SiteRoute {
|
||||
Route(.case(Self.project)) {
|
||||
SiteRoute.View.ProjectRoute.router
|
||||
}
|
||||
Route(.case(Self.ductulator)) {
|
||||
SiteRoute.View.DuctulatorRoute.router
|
||||
}
|
||||
Route(.case(Self.user)) {
|
||||
SiteRoute.View.UserRoute.router
|
||||
}
|
||||
@@ -188,6 +201,7 @@ extension SiteRoute.View.ProjectRoute {
|
||||
}
|
||||
|
||||
public enum RoomRoute: Equatable, Sendable {
|
||||
case csv(Room.CSV)
|
||||
case delete(id: Room.ID)
|
||||
case index
|
||||
case submit(Room.Create)
|
||||
@@ -197,6 +211,17 @@ extension SiteRoute.View.ProjectRoute {
|
||||
static let rootPath = "rooms"
|
||||
|
||||
public static let router = OneOf {
|
||||
Route(.case(Self.csv)) {
|
||||
Path {
|
||||
rootPath
|
||||
"csv"
|
||||
}
|
||||
Headers {
|
||||
Field("Content-Type") { "multipart/form-data" }
|
||||
}
|
||||
Method.post
|
||||
Body().map(.memberwise(Room.CSV.init))
|
||||
}
|
||||
Route(.case(Self.delete)) {
|
||||
Path {
|
||||
rootPath
|
||||
@@ -215,14 +240,24 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Method.post
|
||||
Body {
|
||||
FormData {
|
||||
Field("projectID") { Project.ID.parser() }
|
||||
Field("name", .string)
|
||||
Field("heatingLoad") { Double.parser() }
|
||||
Field("coolingTotal") { Double.parser() }
|
||||
Optionally {
|
||||
Field("coolingSensible", default: nil) { Double.parser() }
|
||||
Field("level") {
|
||||
Int.parser()
|
||||
}
|
||||
.map(.memberwise(Room.Level.init(rawValue:)))
|
||||
}
|
||||
Field("heatingLoad") { Double.parser() }
|
||||
Optionally {
|
||||
Field("coolingTotal") { Double.parser() }
|
||||
}
|
||||
Optionally {
|
||||
Field("coolingSensible") { Double.parser() }
|
||||
}
|
||||
Field("registerCount") { Digits() }
|
||||
Optionally {
|
||||
Field("delegatedTo") { Room.ID.parser() }
|
||||
}
|
||||
}
|
||||
.map(.memberwise(Room.Create.init))
|
||||
}
|
||||
@@ -238,6 +273,12 @@ extension SiteRoute.View.ProjectRoute {
|
||||
Optionally {
|
||||
Field("name", .string)
|
||||
}
|
||||
Optionally {
|
||||
Field("level") {
|
||||
Int.parser()
|
||||
}
|
||||
.map(.memberwise(Room.Level.init(rawValue:)))
|
||||
}
|
||||
Optionally {
|
||||
Field("heatingLoad") { Double.parser() }
|
||||
}
|
||||
@@ -873,11 +914,16 @@ extension SiteRoute.View {
|
||||
extension SiteRoute.View {
|
||||
public enum UserRoute: Equatable, Sendable {
|
||||
case profile(Profile)
|
||||
case logout
|
||||
|
||||
static let router = OneOf {
|
||||
Route(.case(Self.profile)) {
|
||||
Profile.router
|
||||
}
|
||||
Route(.case(Self.logout)) {
|
||||
Path { "logout" }
|
||||
Method.get
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -955,6 +1001,49 @@ extension SiteRoute.View.UserRoute {
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.View {
|
||||
public enum DuctulatorRoute: Equatable, Sendable {
|
||||
case index
|
||||
case submit(Form)
|
||||
|
||||
public static let rootPath = "duct-size"
|
||||
|
||||
static let router = OneOf {
|
||||
Route(.case(Self.index)) {
|
||||
Path { rootPath }
|
||||
Method.get
|
||||
}
|
||||
Route(.case(Self.submit)) {
|
||||
Path { rootPath }
|
||||
Method.post
|
||||
Body {
|
||||
FormData {
|
||||
Field("cfm") { Int.parser() }
|
||||
Field("frictionRate") { Double.parser() }
|
||||
Optionally {
|
||||
Field("height") { Int.parser() }
|
||||
}
|
||||
}
|
||||
.map(.memberwise(Form.init))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct Form: Equatable, Sendable {
|
||||
|
||||
public let cfm: Int
|
||||
public let frictionRate: Double
|
||||
public let height: Int?
|
||||
|
||||
public init(cfm: Int, frictionRate: Double, height: Int? = nil) {
|
||||
self.cfm = cfm
|
||||
self.frictionRate = frictionRate
|
||||
self.height = height
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension PageRequest: @retroactive Equatable {
|
||||
public static func == (lhs: FluentKit.PageRequest, rhs: FluentKit.PageRequest) -> Bool {
|
||||
lhs.page == rhs.page && lhs.per == rhs.per
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Dependencies
|
||||
import DependenciesMacros
|
||||
import Elementary
|
||||
import EnvClient
|
||||
import EnvVars
|
||||
import FileClient
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
@@ -46,9 +46,8 @@ extension PdfClient: DependencyKey {
|
||||
},
|
||||
generatePdf: { projectID, html in
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
@Dependency(\.env) var env
|
||||
@Dependency(\.environment) var environment
|
||||
|
||||
let envVars = try env()
|
||||
let baseUrl = "/tmp/\(projectID)"
|
||||
try await fileClient.writeFile(html.render(), "\(baseUrl).html")
|
||||
|
||||
@@ -57,10 +56,10 @@ extension PdfClient: DependencyKey {
|
||||
let standardOutput = Pipe()
|
||||
process.standardInput = standardInput
|
||||
process.standardOutput = standardOutput
|
||||
process.executableURL = URL(fileURLWithPath: envVars.pandocPath)
|
||||
process.executableURL = URL(fileURLWithPath: environment.pandocPath)
|
||||
process.arguments = [
|
||||
"\(baseUrl).html",
|
||||
"--pdf-engine=\(envVars.pdfEngine)",
|
||||
"--pdf-engine=\(environment.pdfEngine)",
|
||||
"--from=html",
|
||||
"--css=Public/css/pdf.css",
|
||||
"--output=\(baseUrl).pdf",
|
||||
@@ -151,7 +150,12 @@ extension PdfClient {
|
||||
project: project,
|
||||
rooms: rooms,
|
||||
componentLosses: ComponentPressureLoss.mock(projectID: project.id),
|
||||
ductSizes: .mock(equipmentInfo: equipmentInfo, rooms: rooms, trunks: trunks),
|
||||
ductSizes: .mock(
|
||||
equipmentInfo: equipmentInfo,
|
||||
rooms: rooms,
|
||||
trunks: trunks,
|
||||
shr: project.sensibleHeatRatio ?? 0.83
|
||||
),
|
||||
equipmentInfo: equipmentInfo,
|
||||
maxSupplyTEL: equivalentLengths.first { $0.type == .supply }!,
|
||||
maxReturnTEL: equivalentLengths.first { $0.type == .return }!,
|
||||
|
||||
@@ -21,10 +21,9 @@ struct RoomsTable: HTML, Sendable {
|
||||
tr {
|
||||
td { room.name }
|
||||
td { room.heatingLoad.string(digits: 0) }
|
||||
td { room.coolingTotal.string(digits: 0) }
|
||||
td { try! room.coolingLoad.ensured(shr: projectSHR).total.string(digits: 0) }
|
||||
td {
|
||||
(room.coolingSensible
|
||||
?? (room.coolingTotal * projectSHR)).string(digits: 0)
|
||||
try! room.coolingLoad.ensured(shr: projectSHR).sensible.string(digits: 0)
|
||||
}
|
||||
td { room.registerCount.string() }
|
||||
}
|
||||
@@ -37,10 +36,10 @@ struct RoomsTable: HTML, Sendable {
|
||||
rooms.totalHeatingLoad.string(digits: 0)
|
||||
}
|
||||
td(.class("coolingTotal label")) {
|
||||
rooms.totalCoolingLoad.string(digits: 0)
|
||||
try! rooms.totalCoolingLoad(shr: projectSHR).string(digits: 0)
|
||||
}
|
||||
td(.class("coolingSensible label")) {
|
||||
rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
|
||||
try! rooms.totalCoolingSensible(shr: projectSHR).string(digits: 0)
|
||||
}
|
||||
td {}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ extension ManualDClient {
|
||||
)
|
||||
}
|
||||
|
||||
// FIX: Need to add the loads for rooms that get delegated to other rooms here.
|
||||
func calculateRoomSizes(
|
||||
rooms: [Room],
|
||||
sharedRequest: DuctSizeSharedRequest,
|
||||
@@ -41,11 +42,16 @@ extension ManualDClient {
|
||||
|
||||
var retval: [DuctSizes.RoomContainer] = []
|
||||
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
|
||||
let totalCoolingSensible = try rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
|
||||
let nonDelegatedRooms = rooms.filter { $0.delegatedTo == nil }
|
||||
|
||||
for room in nonDelegatedRooms {
|
||||
// Get all the rooms that delegate their loads to this room.
|
||||
let delegatedRooms = rooms.filter { $0.delegatedTo == room.id }
|
||||
|
||||
let heatingLoad = room.heatingLoadPerRegister(delegatedRooms: delegatedRooms)
|
||||
let coolingLoad = try room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
|
||||
|
||||
for room in rooms {
|
||||
let heatingLoad = room.heatingLoadPerRegister
|
||||
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: sharedRequest.projectSHR)
|
||||
let heatingPercent = heatingLoad / totalHeatingLoad
|
||||
let coolingPercent = coolingLoad / totalCoolingSensible
|
||||
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
|
||||
@@ -102,11 +108,11 @@ extension ManualDClient {
|
||||
|
||||
var retval = [DuctSizes.TrunkContainer]()
|
||||
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||
let totalCoolingSensible = rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
|
||||
let totalCoolingSensible = try rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
|
||||
|
||||
for trunk in trunks {
|
||||
let heatingLoad = trunk.totalHeatingLoad
|
||||
let coolingLoad = trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
|
||||
let coolingLoad = try trunk.totalCoolingSensible(projectSHR: sharedRequest.projectSHR)
|
||||
let heatingPercent = heatingLoad / totalHeatingLoad
|
||||
let coolingPercent = coolingLoad / totalCoolingSensible
|
||||
let heatingCFM = heatingPercent * Double(sharedRequest.equipmentInfo.heatingCFM)
|
||||
@@ -181,47 +187,34 @@ extension DuctSizes.SizeContainer {
|
||||
}
|
||||
}
|
||||
|
||||
extension Room {
|
||||
// extension TrunkSize.RoomProxy {
|
||||
//
|
||||
// // We need to make sure if registers got removed after a trunk
|
||||
// // was already made / saved that we do not include registers that
|
||||
// // no longer exist.
|
||||
// private var actualRegisterCount: Int {
|
||||
// guard registers.count <= room.registerCount else {
|
||||
// return room.registerCount
|
||||
// }
|
||||
// return registers.count
|
||||
// }
|
||||
//
|
||||
// var totalHeatingLoad: Double {
|
||||
// room.heatingLoadPerRegister() * Double(actualRegisterCount)
|
||||
// }
|
||||
//
|
||||
// func totalCoolingSensible(projectSHR: Double) throws -> Double {
|
||||
// try room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
|
||||
// }
|
||||
// }
|
||||
|
||||
var heatingLoadPerRegister: Double {
|
||||
|
||||
heatingLoad / Double(registerCount)
|
||||
}
|
||||
|
||||
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
|
||||
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
|
||||
return sensible / Double(registerCount)
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkSize.RoomProxy {
|
||||
|
||||
// We need to make sure if registers got removed after a trunk
|
||||
// was already made / saved that we do not include registers that
|
||||
// no longer exist.
|
||||
private var actualRegisterCount: Int {
|
||||
guard registers.count <= room.registerCount else {
|
||||
return room.registerCount
|
||||
}
|
||||
return registers.count
|
||||
}
|
||||
|
||||
var totalHeatingLoad: Double {
|
||||
room.heatingLoadPerRegister * Double(actualRegisterCount)
|
||||
}
|
||||
|
||||
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkSize {
|
||||
|
||||
var totalHeatingLoad: Double {
|
||||
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
|
||||
}
|
||||
|
||||
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
|
||||
}
|
||||
}
|
||||
// extension TrunkSize {
|
||||
//
|
||||
// var totalHeatingLoad: Double {
|
||||
// rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
|
||||
// }
|
||||
//
|
||||
// func totalCoolingSensible(projectSHR: Double) throws -> Double {
|
||||
// try rooms.reduce(into: 0) { $0 += try $1.totalCoolingSensible(projectSHR: projectSHR) }
|
||||
// }
|
||||
// }
|
||||
|
||||
20
Sources/ProjectClient/Internal/Room+loadPerRegister.swift
Normal file
20
Sources/ProjectClient/Internal/Room+loadPerRegister.swift
Normal file
@@ -0,0 +1,20 @@
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
extension Room {
|
||||
|
||||
public func heatingLoadPerRegister(delegatedRooms: [Room]? = nil) -> Double {
|
||||
(heatingLoad + (delegatedRooms?.totalHeatingLoad ?? 0)) / Double(registerCount)
|
||||
}
|
||||
|
||||
public func coolingSensiblePerRegister(
|
||||
projectSHR: Double,
|
||||
delegatedRooms: [Room]? = nil
|
||||
) throws -> Double {
|
||||
let sensible =
|
||||
try coolingLoad.ensured(shr: projectSHR).sensible
|
||||
+ (delegatedRooms?.totalCoolingSensible(shr: projectSHR) ?? 0)
|
||||
|
||||
return sensible / Double(registerCount)
|
||||
}
|
||||
}
|
||||
34
Sources/ProjectClient/Internal/TrunkSize+loads.swift
Normal file
34
Sources/ProjectClient/Internal/TrunkSize+loads.swift
Normal file
@@ -0,0 +1,34 @@
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
|
||||
extension TrunkSize.RoomProxy {
|
||||
|
||||
// We need to make sure if registers got removed after a trunk
|
||||
// was already made / saved that we do not include registers that
|
||||
// no longer exist.
|
||||
private var actualRegisterCount: Int {
|
||||
guard registers.count <= room.registerCount else {
|
||||
return room.registerCount
|
||||
}
|
||||
return registers.count
|
||||
}
|
||||
|
||||
public var totalHeatingLoad: Double {
|
||||
room.heatingLoadPerRegister() * Double(actualRegisterCount)
|
||||
}
|
||||
|
||||
public func totalCoolingSensible(projectSHR: Double) throws -> Double {
|
||||
try room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
|
||||
}
|
||||
}
|
||||
|
||||
extension TrunkSize {
|
||||
|
||||
public var totalHeatingLoad: Double {
|
||||
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
|
||||
}
|
||||
|
||||
public func totalCoolingSensible(projectSHR: Double) throws -> Double {
|
||||
try rooms.reduce(into: 0) { $0 += try $1.totalCoolingSensible(projectSHR: projectSHR) }
|
||||
}
|
||||
}
|
||||
@@ -27,11 +27,4 @@ extension Badge where Inner == Number {
|
||||
self.inner = Number(number, digits: digits)
|
||||
}
|
||||
|
||||
public init<T>(number: Tagged<T, Int>) {
|
||||
self.inner = Number(number.rawValue)
|
||||
}
|
||||
|
||||
public init<T>(number: Tagged<T, Double>, digits: Int = 2) {
|
||||
self.inner = Number(number.rawValue, digits: digits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import Elementary
|
||||
import ElementaryHTMX
|
||||
import ManualDCore
|
||||
|
||||
public struct SubmitButton: HTML, Sendable {
|
||||
let title: String
|
||||
@@ -26,32 +28,6 @@ public struct SubmitButton: HTML, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct CancelButton: HTML, Sendable {
|
||||
let title: String
|
||||
let type: HTMLAttribute<HTMLTag.button>.ButtonType
|
||||
|
||||
public init(
|
||||
title: String = "Cancel",
|
||||
type: HTMLAttribute<HTMLTag.button>.ButtonType = .button
|
||||
) {
|
||||
self.title = title
|
||||
self.type = type
|
||||
}
|
||||
|
||||
public var body: some HTML<HTMLTag.button> {
|
||||
button(
|
||||
.class(
|
||||
"""
|
||||
text-white font-bold text-xl bg-red-500 hover:bg-red-600 px-4 py-2 rounded-lg shadow-lg
|
||||
"""
|
||||
),
|
||||
.type(type)
|
||||
) {
|
||||
title
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct EditButton: HTML, Sendable {
|
||||
let title: String?
|
||||
let type: HTMLAttribute<HTMLTag.button>.ButtonType
|
||||
@@ -100,3 +76,17 @@ public struct TrashButton: HTML, Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public struct DuctulatorButton: HTML, Sendable {
|
||||
public init() {}
|
||||
|
||||
public var body: some HTML<HTMLTag.a> {
|
||||
a(
|
||||
.class("btn"),
|
||||
.href(route: .ductulator(.index)),
|
||||
.target(.blank)
|
||||
) {
|
||||
"Ductulator"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,6 @@ extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag == HTMLTag.form {
|
||||
|
||||
public static func action(route: SiteRoute.View) -> Self {
|
||||
action(SiteRoute.View.router.path(for: route))
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag == HTMLTag.input {
|
||||
|
||||
public static func value(_ string: String?) -> Self {
|
||||
@@ -42,9 +35,6 @@ extension HTMLAttribute where Tag == HTMLTag.button {
|
||||
}
|
||||
|
||||
extension HTML where Tag: HTMLTrait.Attributes.Global {
|
||||
public func badge() -> _AttributedElement<Self> {
|
||||
attributes(.class("badge badge-lg badge-outline"))
|
||||
}
|
||||
|
||||
public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> {
|
||||
attributes(.class("hidden"), when: shouldHide)
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import Elementary
|
||||
|
||||
// TODO: Remove, using svg's.
|
||||
public struct Icon: HTML, Sendable {
|
||||
|
||||
let icon: String
|
||||
|
||||
public init(icon: String) {
|
||||
self.icon = icon
|
||||
}
|
||||
|
||||
public var body: some HTML {
|
||||
i(.data("lucide", value: icon)) {}
|
||||
}
|
||||
}
|
||||
|
||||
extension Icon {
|
||||
|
||||
public init(_ icon: Key) {
|
||||
self.init(icon: icon.icon)
|
||||
}
|
||||
|
||||
public enum Key: String {
|
||||
|
||||
case circlePlus
|
||||
case close
|
||||
case doorClosed
|
||||
case mapPin
|
||||
case rulerDimensionLine
|
||||
case squareFunction
|
||||
case wind
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .circlePlus: return "circle-plus"
|
||||
case .close: return "x"
|
||||
case .doorClosed: return "door-closed"
|
||||
case .mapPin: return "map-pin"
|
||||
case .rulerDimensionLine: return "ruler-dimension-line"
|
||||
case .squareFunction: return "square-function"
|
||||
case .wind: return rawValue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,60 +21,6 @@ public struct LabeledInput: HTML, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct Input: HTML, Sendable {
|
||||
|
||||
let id: String?
|
||||
let name: String?
|
||||
let placeholder: String
|
||||
|
||||
private var _name: String {
|
||||
guard let name else {
|
||||
return id ?? ""
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
init(
|
||||
id: String? = nil,
|
||||
name: String? = nil,
|
||||
placeholder: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
public init(
|
||||
id: String,
|
||||
name: String? = nil,
|
||||
placeholder: String
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.placeholder = placeholder
|
||||
}
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
placeholder: String
|
||||
) {
|
||||
self.init(id: nil, name: name, placeholder: placeholder)
|
||||
}
|
||||
|
||||
public var body: some HTML<HTMLTag.input> {
|
||||
input(
|
||||
.id(id ?? ""), .name(_name), .placeholder(placeholder),
|
||||
.class(
|
||||
"""
|
||||
input w-full rounded-md bg-white px-3 py-1.5 text-slate-900 outline-1
|
||||
-outline-offset-1 outline-slate-300 focus:outline focus:-outline-offset-2
|
||||
focus:outline-indigo-600 invalid:border-red-500 out-of-range:border-red-500
|
||||
"""
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag == HTMLTag.input {
|
||||
|
||||
public static func max(_ value: String) -> Self {
|
||||
|
||||
46
Sources/Styleguide/MultiSelect.swift
Normal file
46
Sources/Styleguide/MultiSelect.swift
Normal file
@@ -0,0 +1,46 @@
|
||||
import Elementary
|
||||
|
||||
extension HTMLTag {
|
||||
public enum daisyMultiSelect: HTMLTrait.Paired { public static let name = "daisy-multiselect" }
|
||||
}
|
||||
public typealias daisyMultiSelect<Content: HTML> = HTMLElement<HTMLTag.daisyMultiSelect, Content>
|
||||
|
||||
extension HTMLTrait.Attributes {
|
||||
public protocol chipStyle {}
|
||||
public protocol showSelectAll {}
|
||||
public protocol showClear {}
|
||||
public protocol virtualScroll {}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag: HTMLTrait.Attributes.chipStyle {
|
||||
public static var chipStyle: Self {
|
||||
HTMLAttribute(name: "chip-style", value: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag: HTMLTrait.Attributes.showSelectAll {
|
||||
public static var showSelectAll: Self {
|
||||
HTMLAttribute(name: "show-select-all", value: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag: HTMLTrait.Attributes.showClear {
|
||||
public static var showClear: Self {
|
||||
HTMLAttribute(name: "show-clear", value: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLAttribute where Tag: HTMLTrait.Attributes.virtualScroll {
|
||||
public static var virtualScroll: Self {
|
||||
HTMLAttribute(name: "virtual-scroll", value: nil)
|
||||
}
|
||||
}
|
||||
|
||||
extension HTMLTag.daisyMultiSelect: HTMLTrait.Attributes.required {}
|
||||
extension HTMLTag.daisyMultiSelect: HTMLTrait.Attributes.disabled {}
|
||||
extension HTMLTag.daisyMultiSelect: HTMLTrait.Attributes.placeholder {}
|
||||
extension HTMLTag.daisyMultiSelect: HTMLTrait.Attributes.name {}
|
||||
extension HTMLTag.daisyMultiSelect: HTMLTrait.Attributes.chipStyle {}
|
||||
extension HTMLTag.daisyMultiSelect: HTMLTrait.Attributes.showSelectAll {}
|
||||
extension HTMLTag.daisyMultiSelect: HTMLTrait.Attributes.showClear {}
|
||||
extension HTMLTag.daisyMultiSelect: HTMLTrait.Attributes.virtualScroll {}
|
||||
@@ -6,15 +6,6 @@ public struct Number: HTML, Sendable {
|
||||
let fractionDigits: Int
|
||||
let value: Double
|
||||
|
||||
// private var formatter: NumberFormatter {
|
||||
// let formatter = NumberFormatter()
|
||||
// formatter.maximumFractionDigits = fractionDigits
|
||||
// formatter.numberStyle = .decimal
|
||||
// formatter.groupingSize = 3
|
||||
// formatter.groupingSeparator = ","
|
||||
// return formatter
|
||||
// }
|
||||
|
||||
public init(
|
||||
_ value: Double,
|
||||
digits fractionDigits: Int = 2
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
import Validations
|
||||
|
||||
public struct ResultView<ValueView, ErrorView>: HTML where ValueView: HTML, ErrorView: HTML {
|
||||
|
||||
@@ -69,7 +70,11 @@ public struct ErrorView: HTML, Sendable {
|
||||
div {
|
||||
h1(.class("text-xl font-bold text-error")) { "Oops: Error" }
|
||||
p {
|
||||
"\(error.localizedDescription)"
|
||||
if let validationError = (error as? ValidationError) {
|
||||
"\(validationError.debugDescription)"
|
||||
} else {
|
||||
"\(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ extension SVG {
|
||||
public enum Key: Sendable {
|
||||
case badgeCheck
|
||||
case ban
|
||||
case calculator
|
||||
case chevronDown
|
||||
case chevronRight
|
||||
case chevronsLeft
|
||||
@@ -26,6 +27,7 @@ extension SVG {
|
||||
case doorClosed
|
||||
case email
|
||||
case fan
|
||||
case filePlusCorner
|
||||
case key
|
||||
case mapPin
|
||||
case rulerDimensionLine
|
||||
@@ -47,6 +49,10 @@ extension SVG {
|
||||
return """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban-icon lucide-ban"><path d="M4.929 4.929 19.07 19.071"/><circle cx="12" cy="12" r="10"/></svg>
|
||||
"""
|
||||
case .calculator:
|
||||
return """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calculator-icon lucide-calculator"><rect width="16" height="20" x="4" y="2" rx="2"/><line x1="8" x2="16" y1="6" y2="6"/><line x1="16" x2="16" y1="14" y2="18"/><path d="M16 10h.01"/><path d="M12 10h.01"/><path d="M8 10h.01"/><path d="M12 14h.01"/><path d="M8 14h.01"/><path d="M12 18h.01"/><path d="M8 18h.01"/></svg>
|
||||
"""
|
||||
case .chevronDown:
|
||||
return """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>
|
||||
@@ -94,6 +100,10 @@ extension SVG {
|
||||
return """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg>
|
||||
"""
|
||||
case .filePlusCorner:
|
||||
return """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-plus-corner-icon lucide-file-plus-corner"><path d="M11.35 22H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v5.35"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M14 19h6"/><path d="M17 16v6"/></svg>
|
||||
"""
|
||||
case .key:
|
||||
return """
|
||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
|
||||
82
Sources/Styleguide/Select.swift
Normal file
82
Sources/Styleguide/Select.swift
Normal file
@@ -0,0 +1,82 @@
|
||||
import Elementary
|
||||
import Foundation
|
||||
|
||||
/// NOTE: This does not have the 'select' class added to it, because it's generally
|
||||
/// added to the label of the field.
|
||||
public struct Select<Label, Element>: HTML where Label: HTML {
|
||||
|
||||
let label: @Sendable (Element) -> Label
|
||||
let value: @Sendable (Element) -> String
|
||||
let selected: @Sendable (Element) -> Bool
|
||||
let items: [Element]
|
||||
let placeholder: String?
|
||||
|
||||
public init(
|
||||
_ items: [Element],
|
||||
placeholder: String? = nil,
|
||||
value: @escaping @Sendable (Element) -> String,
|
||||
selected: @escaping @Sendable (Element) -> Bool = { _ in false },
|
||||
@HTMLBuilder label: @escaping @Sendable (Element) -> Label
|
||||
) {
|
||||
self.label = label
|
||||
self.items = items
|
||||
self.placeholder = placeholder
|
||||
self.selected = selected
|
||||
self.value = value
|
||||
}
|
||||
|
||||
public var body: some HTML<HTMLTag.select> {
|
||||
select {
|
||||
if let placeholder {
|
||||
option(.selected, .disabled) { placeholder }
|
||||
}
|
||||
for item in items {
|
||||
option(.value(value(item))) { label(item) }
|
||||
.attributes(.selected, when: selected(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Select: Sendable where Element: Sendable, Label: Sendable {}
|
||||
|
||||
extension Select where Element: Identifiable, Element.ID == UUID, Element: Sendable {
|
||||
|
||||
public init(
|
||||
_ items: [Element],
|
||||
placeholder: String? = nil,
|
||||
selected: @escaping @Sendable (Element) -> Bool = { _ in false },
|
||||
@HTMLBuilder label: @escaping @Sendable (Element) -> Label
|
||||
) {
|
||||
self.init(
|
||||
items,
|
||||
placeholder: placeholder,
|
||||
value: { $0.id.uuidString },
|
||||
selected: selected,
|
||||
label: label
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension Select
|
||||
where Element: Identifiable, Element.ID == UUID, Element: Sendable, Label == HTMLText {
|
||||
|
||||
public init(
|
||||
_ items: [Element],
|
||||
label keyPath: KeyPath<Element, String>,
|
||||
placeholder: String? = nil,
|
||||
selected: @escaping @Sendable (Element) -> Bool = { _ in false }
|
||||
) {
|
||||
self.init(
|
||||
items,
|
||||
placeholder: placeholder,
|
||||
value: { $0.id.uuidString },
|
||||
selected: selected,
|
||||
label: { HTMLText($0[keyPath: keyPath]) }
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -53,6 +53,13 @@ extension ViewController: DependencyKey {
|
||||
|
||||
extension ViewController.Request {
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
if (try? currentUser()) != nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func currentUser() throws -> User {
|
||||
@Dependency(\.auth.currentUser) var currentUser
|
||||
return try currentUser()
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import CSVParser
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import Elementary
|
||||
import Foundation
|
||||
import ManualDClient
|
||||
import ManualDCore
|
||||
import PdfClient
|
||||
import ProjectClient
|
||||
@@ -16,6 +18,14 @@ extension ViewController.Request {
|
||||
@Dependency(\.pdfClient) var pdfClient
|
||||
|
||||
switch route {
|
||||
case .home:
|
||||
return await view {
|
||||
HomeView()
|
||||
}
|
||||
case .privacyPolicy:
|
||||
return await view {
|
||||
PrivacyPolicyView()
|
||||
}
|
||||
case .test:
|
||||
// let projectID = UUID(uuidString: "E796C96C-F527-4753-A00A-EBCF25630663")!
|
||||
// return await view {
|
||||
@@ -33,7 +43,9 @@ extension ViewController.Request {
|
||||
// }
|
||||
// }
|
||||
// return try! await pdfClient.html(.mock())
|
||||
return EmptyHTML()
|
||||
return await view {
|
||||
TestPage()
|
||||
}
|
||||
case .login(let route):
|
||||
switch route {
|
||||
case .index(let next):
|
||||
@@ -88,6 +100,9 @@ extension ViewController.Request {
|
||||
case .project(let route):
|
||||
return await route.renderView(on: self)
|
||||
|
||||
case .ductulator(let route):
|
||||
return await route.renderView(on: self)
|
||||
|
||||
case .user(let route):
|
||||
return await route.renderView(on: self)
|
||||
}
|
||||
@@ -99,7 +114,7 @@ extension ViewController.Request {
|
||||
let inner = await inner()
|
||||
let theme = await self.theme
|
||||
|
||||
return MainPage(displayFooter: displayFooter, theme: theme) {
|
||||
return MainPage(displayFooter: displayFooter, theme: theme ?? .default) {
|
||||
inner
|
||||
}
|
||||
}
|
||||
@@ -284,10 +299,18 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
|
||||
on request: ViewController.Request,
|
||||
projectID: Project.ID
|
||||
) async -> AnySendableHTML {
|
||||
@Dependency(\.csvParser) var csvParser
|
||||
@Dependency(\.database) var database
|
||||
|
||||
switch self {
|
||||
|
||||
case .csv(let csv):
|
||||
return await roomsView(on: request, projectID: projectID) {
|
||||
let rooms = try await csvParser.parseRooms(csv)
|
||||
_ = try await database.rooms.createFromCSV(projectID, rooms)
|
||||
}
|
||||
// return EmptyHTML()
|
||||
|
||||
case .delete(let id):
|
||||
return await ResultView {
|
||||
try await database.rooms.delete(id)
|
||||
@@ -298,7 +321,7 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
|
||||
|
||||
case .submit(let form):
|
||||
return await roomsView(on: request, projectID: projectID) {
|
||||
_ = try await database.rooms.create(form)
|
||||
_ = try await database.rooms.create(projectID, form)
|
||||
}
|
||||
|
||||
case .update(let id, let form):
|
||||
@@ -592,6 +615,7 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
|
||||
try await database.trunkSizes.delete(id)
|
||||
}
|
||||
case .submit(let form):
|
||||
request.logger.debug("Trunk Form: \(form)")
|
||||
return await view(on: request, projectID: projectID) {
|
||||
_ = try await database.trunkSizes.create(
|
||||
form.toCreate(logger: request.logger)
|
||||
@@ -633,7 +657,17 @@ extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
|
||||
extension SiteRoute.View.UserRoute {
|
||||
|
||||
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
|
||||
@Dependency(\.auth) var auth
|
||||
|
||||
switch self {
|
||||
case .logout:
|
||||
return await request.view {
|
||||
await ResultView {
|
||||
try auth.logout()
|
||||
} onSuccess: {
|
||||
LoginForm(next: nil)
|
||||
}
|
||||
}
|
||||
case .profile(let route):
|
||||
return await route.renderView(on: request)
|
||||
}
|
||||
@@ -681,3 +715,33 @@ extension SiteRoute.View.UserRoute.Profile {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension SiteRoute.View.DuctulatorRoute {
|
||||
|
||||
func renderView(
|
||||
on request: ViewController.Request
|
||||
) async -> AnySendableHTML {
|
||||
@Dependency(\.manualD) var manualD
|
||||
|
||||
switch self {
|
||||
case .index:
|
||||
return await request.view {
|
||||
DuctulatorView(
|
||||
isLoggedIn: request.isLoggedIn
|
||||
)
|
||||
}
|
||||
case .submit(let form):
|
||||
return await ResultView {
|
||||
let ductSize = try await manualD.ductSize(cfm: form.cfm, frictionRate: form.frictionRate)
|
||||
var rectangularSize: ManualDClient.RectangularSize? = nil
|
||||
if let height = form.height {
|
||||
rectangularSize = try await manualD.rectangularSize(
|
||||
round: ductSize.finalSize, height: height)
|
||||
}
|
||||
return (ductSize, rectangularSize)
|
||||
} onSuccess: { (ductSize, rectangularSize) in
|
||||
DuctulatorView.Result(ductSize: ductSize, rectangularSize: rectangularSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ struct TrunkSizeForm: HTML, Sendable {
|
||||
}
|
||||
|
||||
var body: some HTML {
|
||||
script(.src("/js/daisy-multiselect.js")) {}
|
||||
ModalForm(id: Self.id(container), dismiss: dismiss) {
|
||||
h1(.class("text-lg font-bold mb-4")) { "Trunk / Runout Size" }
|
||||
form(
|
||||
@@ -77,40 +78,33 @@ struct TrunkSizeForm: HTML, Sendable {
|
||||
.type(.text),
|
||||
.name("name"),
|
||||
.value(trunk?.name),
|
||||
.placeholder("Trunk-1 (Optional)")
|
||||
.placeholder("Trunk-1"),
|
||||
.required
|
||||
)
|
||||
|
||||
div {
|
||||
h2(.class("label font-bold col-span-3 mb-6")) { "Associated Supply Runs" }
|
||||
div(
|
||||
.class(
|
||||
"""
|
||||
grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 justify-center items-center gap-4
|
||||
"""
|
||||
)
|
||||
daisyMultiSelect(
|
||||
.class("z-50 bg-base-200"),
|
||||
.placeholder("Select rooms"),
|
||||
.name("rooms"),
|
||||
.chipStyle,
|
||||
.showSelectAll,
|
||||
.showClear,
|
||||
.required,
|
||||
.virtualScroll
|
||||
) {
|
||||
for room in rooms {
|
||||
div(.class("block grow")) {
|
||||
div(.class("grid grid-cols-1 space-y-1")) {
|
||||
div(.class("flex justify-center")) {
|
||||
p(.class("label")) { room.roomName }
|
||||
}
|
||||
div(.class("flex justify-center")) {
|
||||
input(
|
||||
.class("checkbox"),
|
||||
.type(.checkbox),
|
||||
.name("rooms"),
|
||||
.value("\(room.roomID)_\(room.roomRegister)")
|
||||
)
|
||||
.attributes(
|
||||
.checked,
|
||||
when: trunk == nil ? false : trunk!.rooms.hasRoom(room)
|
||||
)
|
||||
}
|
||||
}
|
||||
option(.value("\(room.roomID)_\(room.roomRegister)")) {
|
||||
room.roomName
|
||||
}
|
||||
.attributes(
|
||||
.selected,
|
||||
when: trunk == nil ? false : trunk!.rooms.hasRoom(room)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
SubmitButton()
|
||||
|
||||
141
Sources/ViewController/Views/Ductulator/DuctulatorView.swift
Normal file
141
Sources/ViewController/Views/Ductulator/DuctulatorView.swift
Normal file
@@ -0,0 +1,141 @@
|
||||
import Dependencies
|
||||
import Elementary
|
||||
import ElementaryHTMX
|
||||
import Foundation
|
||||
import ManualDClient
|
||||
import ManualDCore
|
||||
import Styleguide
|
||||
|
||||
struct DuctulatorView: HTML, Sendable {
|
||||
|
||||
let isLoggedIn: Bool
|
||||
|
||||
init(isLoggedIn: Bool = false) {
|
||||
self.isLoggedIn = isLoggedIn
|
||||
}
|
||||
|
||||
var body: some HTML {
|
||||
div {
|
||||
Navbar(
|
||||
showSidebarToggle: false,
|
||||
isLoggedIn: isLoggedIn
|
||||
)
|
||||
div(.class("flex justify-center items-center px-10")) {
|
||||
div(
|
||||
.class(
|
||||
"""
|
||||
bg-base-300 rounded-3xl shadow-3xl
|
||||
p-6 w-full
|
||||
"""
|
||||
)
|
||||
) {
|
||||
div(.class("flex space-x-6 items-center text-4xl")) {
|
||||
SVG(.calculator)
|
||||
h1(.class("text-4xl font-bold me-10")) {
|
||||
"Ductulator"
|
||||
}
|
||||
}
|
||||
|
||||
p(.class("text-primary font-bold italic")) {
|
||||
"Calculate duct size for the given parameters"
|
||||
}
|
||||
|
||||
form(
|
||||
.class("space-y-4 mt-6"),
|
||||
.hx.post(route: .ductulator(.index)),
|
||||
.hx.target("#\(Result.id)"),
|
||||
.hx.swap(.outerHTML)
|
||||
) {
|
||||
LabeledInput(
|
||||
"CFM",
|
||||
.name("cfm"),
|
||||
.type(.number),
|
||||
.placeholder("1000"),
|
||||
.required,
|
||||
.autofocus
|
||||
)
|
||||
|
||||
LabeledInput(
|
||||
"Friction Rate",
|
||||
.name("frictionRate"),
|
||||
.value("0.06"),
|
||||
.required,
|
||||
.type(.number),
|
||||
.min("0.01"),
|
||||
.step("0.01")
|
||||
)
|
||||
|
||||
LabeledInput(
|
||||
"Height",
|
||||
.name("height"),
|
||||
.type(.number),
|
||||
.placeholder("Height (Optional)"),
|
||||
)
|
||||
|
||||
SubmitButton()
|
||||
.attributes(.class("btn-block mt-6"))
|
||||
}
|
||||
|
||||
// Populate when submitted
|
||||
div(.id(Result.id)) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Result: HTML, Sendable {
|
||||
static let id = "resultView"
|
||||
|
||||
let ductSize: ManualDClient.DuctSize
|
||||
let rectangularSize: ManualDClient.RectangularSize?
|
||||
|
||||
var body: some HTML<HTMLTag.div> {
|
||||
div(
|
||||
.id(Self.id),
|
||||
.class(
|
||||
"""
|
||||
border-2 border-accent rounded-lg shadow-lg
|
||||
w-full p-6 my-6
|
||||
"""
|
||||
)
|
||||
) {
|
||||
div(.class("flex justify-between p-4")) {
|
||||
h2(.class("text-3xl font-bold")) { "Result" }
|
||||
button(
|
||||
.class("btn btn-primary"),
|
||||
.hx.get(route: .ductulator(.index)),
|
||||
.hx.target("body"),
|
||||
.hx.swap(.outerHTML)
|
||||
) {
|
||||
"Reset"
|
||||
}
|
||||
.tooltip("Reset form", position: .left)
|
||||
}
|
||||
|
||||
table(.class("table table-zebra text-lg font-bold")) {
|
||||
tbody {
|
||||
tr {
|
||||
td { Label("Calculated Size") }
|
||||
td { Number(ductSize.calculatedSize, digits: 2) }
|
||||
}
|
||||
tr {
|
||||
td { Label("Final Size") }
|
||||
td { Number(ductSize.finalSize) }
|
||||
}
|
||||
tr {
|
||||
td { Label("Flex Size") }
|
||||
td { Number(ductSize.flexSize) }
|
||||
}
|
||||
if let rectangularSize {
|
||||
tr {
|
||||
td { Label("Rectangular Size") }
|
||||
td { "\(rectangularSize.width) x \(rectangularSize.height)" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import Elementary
|
||||
import ManualDCore
|
||||
import Styleguide
|
||||
|
||||
// TODO: Have form hold onto equipment info model to edit.
|
||||
struct EquipmentInfoForm: HTML, Sendable {
|
||||
|
||||
static let id = "equipmentForm"
|
||||
|
||||
155
Sources/ViewController/Views/Home.swift
Normal file
155
Sources/ViewController/Views/Home.swift
Normal file
@@ -0,0 +1,155 @@
|
||||
import Elementary
|
||||
import ElementaryHTMX
|
||||
import Styleguide
|
||||
|
||||
struct HomeView: HTML, Sendable {
|
||||
|
||||
var body: some HTML {
|
||||
div( // Uncomment to test different theme's.
|
||||
// .data("theme", value: "cyberpunk")
|
||||
// NOTE: Footer background color will follow system theme.
|
||||
) {
|
||||
div(.class("flex justify-end space-x-4 m-4")) {
|
||||
DuctulatorButton()
|
||||
.attributes(.class("btn-ghost btn-accent text-lg"))
|
||||
.tooltip("Duct size calculator", position: .left)
|
||||
|
||||
button(
|
||||
.class("btn btn-ghost btn-secondary text-lg"),
|
||||
.hx.get(route: .login(.index())),
|
||||
.hx.target("body"),
|
||||
.hx.swap(.outerHTML),
|
||||
.hx.pushURL(true)
|
||||
) {
|
||||
"Login"
|
||||
}
|
||||
}
|
||||
|
||||
div(.class("mx-10 lg:mx-20")) {
|
||||
div(
|
||||
.class(
|
||||
"""
|
||||
relative text-center bg-base-300
|
||||
rounded-3xl shadow-3xl overflow-hidden
|
||||
"""
|
||||
)
|
||||
) {
|
||||
div(
|
||||
.class(
|
||||
"""
|
||||
bg-secondary text-xl font-bold
|
||||
absolute top-10 -left-15
|
||||
px-6 py-2 w-[250px] -rotate-45
|
||||
"""
|
||||
)
|
||||
) {
|
||||
"BETA"
|
||||
}
|
||||
div {
|
||||
header
|
||||
a(
|
||||
.class("btn btn-ghost text-md text-primary font-bold italic"),
|
||||
.href("https://github.com/m-housh/swift-duct-calc"),
|
||||
.target(.blank)
|
||||
) {
|
||||
"Open source residential duct design program"
|
||||
}
|
||||
p(.class("text-3xl py-6")) {
|
||||
"""
|
||||
Manual-D™ speed sheet, but on the web!
|
||||
"""
|
||||
}
|
||||
button(
|
||||
.class("btn btn-xl btn-primary mt-6"),
|
||||
.hx.get(route: .signup(.index)),
|
||||
.hx.target("body"),
|
||||
.hx.swap(.outerHTML),
|
||||
.hx.pushURL(true)
|
||||
) {
|
||||
"Get Started"
|
||||
}
|
||||
p(.class("text-xs italic my-6")) {
|
||||
"""
|
||||
Manual-D™ is a trademark of Air Conditioning Contractors of America (ACCA).
|
||||
|
||||
This site is not designed by or affiliated with ACCA.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(.class("grid grid-cols-1 md:grid-cols-2 gap-4 my-6")) {
|
||||
div(.class("border-3 border-accent rounded-lg shadow-lg p-4")) {
|
||||
div(.class("flex items-center space-x-4")) {
|
||||
div(.class("text-5xl text-primary font-bold")) {
|
||||
"Features"
|
||||
}
|
||||
}
|
||||
div(.class("text-xl ms-10 mt-10")) {
|
||||
ul(.class("list-disc")) {
|
||||
li {
|
||||
div(
|
||||
.class("font-bold italic bg-secondary rounded-lg shadow-lg px-4 w-fit")
|
||||
) {
|
||||
"Built by humans"
|
||||
}
|
||||
}
|
||||
li { "Fully open source." }
|
||||
li { "Great replacement for speed sheet users." }
|
||||
li { "Great for classrooms." }
|
||||
li { "Store your projects in one place." }
|
||||
li { "Export final project to pdf." }
|
||||
li { "Import room loads via CSV file." }
|
||||
li { "Web based." }
|
||||
li { "Self host (run on your own infrastructure)." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div(.class("border-3 border-accent rounded-lg shadow-lg p-4")) {
|
||||
div(.class("text-5xl text-primary font-bold")) {
|
||||
"Coming Soon"
|
||||
}
|
||||
div(.class("text-xl ms-10 mt-10")) {
|
||||
ul(.class("list-disc")) {
|
||||
li { "API integration." }
|
||||
li { "Command line interface." }
|
||||
li { "Fitting selection tool." }
|
||||
li { "Room load import from PDF." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: When beta flag is gone, then remove the responsive margin of the header.
|
||||
var header: some HTML<HTMLTag.div> {
|
||||
div(.class("flex justify-center mt-30 md:mt-15 lg:mt-6")) {
|
||||
div(
|
||||
.class(
|
||||
"""
|
||||
flex items-end border-b-6 border-accent
|
||||
text-8xl font-bold my-auto space-2
|
||||
"""
|
||||
)
|
||||
) {
|
||||
h1(.class("me-2")) { "Duct Calc" }
|
||||
div {
|
||||
span(
|
||||
.class(
|
||||
"""
|
||||
bg-secondary rounded-md
|
||||
text-5xl rotate-180 p-2 -mx-2
|
||||
"""
|
||||
),
|
||||
.style("writing-mode: vertical-rl")
|
||||
) {
|
||||
"Pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
|
||||
script(.src("https://unpkg.com/htmx.org@2.0.8")) {}
|
||||
script(.src("/js/htmx-download.js")) {}
|
||||
script(.src("/js/main.js")) {}
|
||||
script(.src("https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4")) {}
|
||||
link(.rel(.stylesheet), .href("/css/output.css"))
|
||||
link(.rel(.stylesheet), .href("/css/htmx.css"))
|
||||
link(
|
||||
@@ -93,13 +94,34 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
|
||||
footer(
|
||||
.class(
|
||||
"""
|
||||
footer sm:footer-horizontal footer-center
|
||||
footer footer-horizontal footer-center
|
||||
bg-base-300 text-base-content p-4
|
||||
"""
|
||||
)
|
||||
) {
|
||||
aside {
|
||||
p {
|
||||
aside(
|
||||
.class("grid-flow-row items-center")
|
||||
) {
|
||||
|
||||
div(.class("flex mx-auto")) {
|
||||
a(
|
||||
.class("btn btn-ghost"),
|
||||
.href("mailto:support@ductcalc.pro")
|
||||
) {
|
||||
SVG(.email)
|
||||
span { "support@ductcalc.pro" }
|
||||
}
|
||||
}
|
||||
|
||||
a(
|
||||
.class("btn btn-ghost mx-auto"),
|
||||
.href("https://github.com/m-housh/swift-duct-calc/src/branch/main/LICENSE"),
|
||||
.target(.blank)
|
||||
) {
|
||||
"Openly licensed via CC-BY-NC-SA 4.0"
|
||||
}
|
||||
|
||||
p(.class("")) {
|
||||
"Copyright © \(Date().description.prefix(4)) - All rights reserved by Michael Housh"
|
||||
}
|
||||
}
|
||||
@@ -108,6 +130,7 @@ public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable
|
||||
}
|
||||
}
|
||||
.attributes(.data("theme", value: theme?.rawValue ?? "default"), when: theme != nil)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,15 +3,22 @@ import ManualDCore
|
||||
import Styleguide
|
||||
|
||||
struct Navbar: HTML, Sendable {
|
||||
let sidebarToggle: Bool
|
||||
let userProfile: Bool
|
||||
let showSidebarToggle: Bool
|
||||
let isLoggedIn: Bool
|
||||
|
||||
init(
|
||||
sidebarToggle: Bool,
|
||||
userProfile: Bool = true
|
||||
showSidebarToggle: Bool,
|
||||
isLoggedIn: Bool = true
|
||||
) {
|
||||
self.sidebarToggle = sidebarToggle
|
||||
self.userProfile = userProfile
|
||||
self.showSidebarToggle = showSidebarToggle
|
||||
self.isLoggedIn = isLoggedIn
|
||||
}
|
||||
|
||||
var homeRoute: SiteRoute.View {
|
||||
if isLoggedIn {
|
||||
return .project(.index)
|
||||
}
|
||||
return .home
|
||||
}
|
||||
|
||||
var body: some HTML<HTMLTag.nav> {
|
||||
@@ -23,21 +30,21 @@ struct Navbar: HTML, Sendable {
|
||||
)
|
||||
) {
|
||||
div(.class("flex flex-1 space-x-4 items-center")) {
|
||||
if sidebarToggle {
|
||||
if showSidebarToggle {
|
||||
label(
|
||||
.for("my-drawer-1"),
|
||||
.class("size-7"),
|
||||
.init(name: "aria-label", value: "open sidebar")
|
||||
.init(name: "aria-label", value: "open / close sidebar")
|
||||
) {
|
||||
SVG(.sidebarToggle)
|
||||
}
|
||||
.navButton()
|
||||
.tooltip("Open sidebar", position: .right)
|
||||
.tooltip("Open / close sidebar", position: .right)
|
||||
}
|
||||
|
||||
a(
|
||||
.class("flex w-fit h-fit text-xl items-end px-4 py-2"),
|
||||
.href(route: .project(.index))
|
||||
.class("flex w-fit h-fit text-2xl items-end px-4 py-2"),
|
||||
.href(route: homeRoute)
|
||||
) {
|
||||
img(
|
||||
.src("/images/mand_logo_sm.webp"),
|
||||
@@ -45,18 +52,35 @@ struct Navbar: HTML, Sendable {
|
||||
span { "Duct Calc" }
|
||||
}
|
||||
.navButton()
|
||||
.tooltip("Home", position: .right)
|
||||
.tooltip(isLoggedIn ? "Projects" : "Home", position: .right)
|
||||
}
|
||||
if userProfile {
|
||||
// TODO: Make dropdown
|
||||
div(.class("flex-none")) {
|
||||
a(
|
||||
.href(route: .user(.profile(.index))),
|
||||
) {
|
||||
SVG(.circleUser)
|
||||
|
||||
div(.class("flex-none")) {
|
||||
div(.class("flex items-end space-x-4")) {
|
||||
|
||||
DuctulatorButton()
|
||||
.attributes(.class("btn-ghost btn-primary text-lg"))
|
||||
.tooltip("Duct size calculator", position: .left)
|
||||
|
||||
if isLoggedIn {
|
||||
div(.class("dropdown dropdown-end dropdown-hover")) {
|
||||
div(.class("btn m-1"), .tabindex(0), .role("button")) {
|
||||
SVG(.circleUser)
|
||||
}
|
||||
.navButton()
|
||||
ul(
|
||||
.tabindex(-1),
|
||||
.class("dropdown-content menu bg-base-200 rounded-box z-1 w-52 py-2 shadow-sm")
|
||||
) {
|
||||
li {
|
||||
a(.href(route: .user(.profile(.index)))) { "Profile" }
|
||||
}
|
||||
li {
|
||||
a(.href(route: .user(.logout))) { "Logout" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navButton()
|
||||
.tooltip("Profile")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
699
Sources/ViewController/Views/PrivacyPolicy.swift
Normal file
699
Sources/ViewController/Views/PrivacyPolicy.swift
Normal file
@@ -0,0 +1,699 @@
|
||||
import Elementary
|
||||
|
||||
struct PrivacyPolicyView: HTML, Sendable {
|
||||
|
||||
var body: some HTML<HTMLTag.div> {
|
||||
div(.class("p-10 space-y-4")) {
|
||||
h1(.class("text-3xl font-bold")) { "Privacy Policy" }
|
||||
p {
|
||||
"Last updated: February 10, 2026"
|
||||
}
|
||||
p {
|
||||
"""
|
||||
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure
|
||||
of Your information when You use the Service and tells You about Your privacy rights and how the
|
||||
law protects You.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
We use Your Personal Data to provide and improve the Service. By using the Service, You agree
|
||||
to the collection and use of information in accordance with this Privacy Policy. This Privacy
|
||||
Policy has been created with the help of the
|
||||
"""
|
||||
a(
|
||||
.href("https://www.termsfeed.com/privacy-policy-generator/"),
|
||||
.target(.blank)
|
||||
) {
|
||||
" TermsFeed Privacy Policy Generator"
|
||||
}
|
||||
}
|
||||
|
||||
h2(.class("text-2xl font-bold")) { "Interpretation and Definitions" }
|
||||
h3(.class("text-xl font-bold")) { "Interpretation" }
|
||||
|
||||
p {
|
||||
"""
|
||||
The words whose initial letters are capitalized have meanings defined under the
|
||||
following conditions. The following definitions shall have the same meaning
|
||||
regardless of whether they appear in singular or in plural.
|
||||
"""
|
||||
}
|
||||
|
||||
h3(.class("text-xl font-bold")) { "Definitions" }
|
||||
|
||||
p { "For the purposes of this Privacy Policy:" }
|
||||
|
||||
ul(.class("list-disc px-6 space-y-2")) {
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Account"
|
||||
}
|
||||
" means a unique account created for You to access our Service or parts of our Service."
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Affiliate"
|
||||
}
|
||||
"""
|
||||
means an entity that controls, is controlled by, or is under common control with a
|
||||
party, where "control" means ownership of 50% or more of the shares, equity interest or other
|
||||
securities entitled to vote for election of directors or other managing authority.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Company"
|
||||
}
|
||||
"""
|
||||
(referred to as either "the Company", "We", "Us" or "Our" in this Privacy Policy)
|
||||
refers to Duct Calc.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Cookies"
|
||||
}
|
||||
"""
|
||||
are small files that are placed on Your computer, mobile device or any other device by
|
||||
a website, containing the details of Your browsing history on that website among its many uses.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Country"
|
||||
}
|
||||
"""
|
||||
refers to: Ohio, United States
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Device"
|
||||
}
|
||||
"""
|
||||
means any device that can access the Service such as a computer, a cell phone or a
|
||||
digital tablet.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Personal Data"
|
||||
}
|
||||
"""
|
||||
(or "Personal Information") is any information that relates to an identified or
|
||||
identifiable individual.
|
||||
|
||||
We use "Personal Data" and "Personal Information" interchangeably unless a law uses a specific
|
||||
term.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Service"
|
||||
}
|
||||
"""
|
||||
refers to the Website.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Service Provider"
|
||||
}
|
||||
"""
|
||||
means any natural or legal person who processes the data on behalf of the
|
||||
Company. It refers to third-party companies or individuals employed by the Company to facilitate
|
||||
the Service, to provide the Service on behalf of the Company, to perform services related to the
|
||||
Service or to assist the Company in analyzing how the Service is used.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Usage Data"
|
||||
}
|
||||
"""
|
||||
refers to data collected automatically, either generated by the use of the Service
|
||||
or from the Service infrastructure itself (for example, the duration of a page visit).
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"Website"
|
||||
}
|
||||
"""
|
||||
refers to Duct Calc, accessible from [https://ductcalc.pro](https://ductcalc.pro).
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) {
|
||||
"You"
|
||||
}
|
||||
"""
|
||||
means the individual accessing or using the Service, or the company, or other legal entity
|
||||
on behalf of which such individual is accessing or using the Service, as applicable.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
h2(.class("text-2xl font-bold")) { "Collecting and Using Your Personal Data" }
|
||||
h3(.class("text-xl font-bold")) { "Types of Data Collected" }
|
||||
h4(.class("text-lg font-bold")) { "Personal Data" }
|
||||
|
||||
p {
|
||||
"""
|
||||
While using Our Service, We may ask You to provide Us with certain personally identifiable
|
||||
information that can be used to contact or identify You. Personally identifiable information may
|
||||
include, but is not limited to:
|
||||
"""
|
||||
}
|
||||
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li { "Email address" }
|
||||
li { "First and last name" }
|
||||
li { "Phone number" }
|
||||
li { "Address, State, Province, ZIP/Postal Code, City" }
|
||||
}
|
||||
|
||||
h4(.class("text-lg font-bold")) { "Usage Data" }
|
||||
p {
|
||||
"""
|
||||
Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP
|
||||
address), browser type, browser version, the pages of our Service that You visit, the time and date
|
||||
of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
|
||||
|
||||
When You access the Service by or through a mobile device, We may collect certain information
|
||||
automatically, including, but not limited to, the type of mobile device You use, Your mobile
|
||||
device's unique ID, the IP address of Your mobile device, Your mobile operating system, the type of
|
||||
mobile Internet browser You use, unique device identifiers and other diagnostic data.
|
||||
|
||||
We may also collect information that Your browser sends whenever You visit Our Service or when You
|
||||
access the Service by or through a mobile device.
|
||||
"""
|
||||
}
|
||||
|
||||
h4(.class("text-lg font-bold")) { "Tracking Technologies and Cookies" }
|
||||
p {
|
||||
"""
|
||||
We use Cookies and similar tracking technologies to track the activity on Our Service and store
|
||||
certain information. Tracking technologies We use include beacons, tags, and scripts to collect and
|
||||
track information and to improve and analyze Our Service. The technologies We use may include:
|
||||
"""
|
||||
}
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
span(.class("font-bold")) { "Cookies or Browser Cookies." }
|
||||
"""
|
||||
A cookie is a small file placed on Your Device. You can instruct
|
||||
Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do
|
||||
not accept Cookies, You may not be able to use some parts of our Service.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "Web Beacons" }
|
||||
"""
|
||||
Certain sections of our Service and our emails may contain small electronic files
|
||||
known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that
|
||||
permit the Company, for example, to count users who have visited those pages or opened an email
|
||||
and for other related website statistics (for example, recording the popularity of a certain
|
||||
section and verifying system and server integrity).
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
"""
|
||||
|
||||
Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies remain on Your personal
|
||||
computer or mobile device when You go offline, while Session Cookies are deleted as soon as You
|
||||
close Your web browser.
|
||||
|
||||
Where required by law, we use non-essential cookies (such as analytics, advertising, and remarketing
|
||||
cookies) only with Your consent. You can withdraw or change Your consent at any time using Our
|
||||
cookie preferences tool (if available) or through Your browser/device settings. Withdrawing consent
|
||||
does not affect the lawfulness of processing based on consent before its withdrawal.
|
||||
|
||||
We use both Session and Persistent Cookies for the purposes set out below:
|
||||
"""
|
||||
}
|
||||
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
p(.class("font-bold")) { "Necessary / Essential Cookies" }
|
||||
p(.class("mx-6")) {
|
||||
"""
|
||||
Type: Session Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies are essential to provide You with services available through the Website
|
||||
and to enable You to use some of its features. They help to authenticate users and prevent
|
||||
fraudulent use of user accounts. Without these Cookies, the services that You have asked for
|
||||
cannot be provided, and We only use these Cookies to provide You with those services.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
p(.class("font-bold")) { "Functionality Cookies" }
|
||||
p(.class("mx-6")) {
|
||||
"""
|
||||
Type: Persistent Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies allow Us to remember choices You make when You use the Website, such as
|
||||
remembering your login details or language preference. The purpose of these Cookies is to
|
||||
provide You with a more personal experience and to avoid You having to re-enter your preferences
|
||||
every time You use the Website.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
"""
|
||||
For more information about the cookies we use and your choices regarding cookies, please visit our
|
||||
Cookies Policy or the Cookies section of Our Privacy Policy.
|
||||
"""
|
||||
}
|
||||
|
||||
h3(.class("text-2xl font-bold")) { "Use of Your Personal Data" }
|
||||
p {
|
||||
"The Company may use Personal Data for the following purposes:"
|
||||
}
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
span(.class("font-bold")) { "To provide and maintain our Service" }
|
||||
"""
|
||||
, including to monitor the usage of our Service.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "To manage Your Account:" }
|
||||
"""
|
||||
to manage Your registration as a user of the Service. The Personal
|
||||
Data You provide can give You access to different functionalities of the Service that are
|
||||
available to You as a registered user.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "For the performance of a contract:" }
|
||||
"""
|
||||
the development, compliance and undertaking of the purchase
|
||||
contract for the products, items or services You have purchased or of any other contract with Us
|
||||
through the Service.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "To contact You:" }
|
||||
"""
|
||||
To contact You by email, telephone calls, SMS, or other equivalent forms of
|
||||
electronic communication, such as a mobile application's push notifications regarding updates or
|
||||
informative communications related to the functionalities, products or contracted services,
|
||||
including the security updates, when necessary or reasonable for their implementation.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "To provide You" }
|
||||
"""
|
||||
with news, special offers, and general information about other goods, services
|
||||
and events which We offer that are similar to those that you have already purchased or inquired
|
||||
about unless You have opted not to receive such information.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "To manage Your requests:" }
|
||||
"""
|
||||
To attend and manage Your requests to Us.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "For business transfers:" }
|
||||
"""
|
||||
We may use Your Personal Data to evaluate or conduct a merger,
|
||||
divestiture, restructuring, reorganization, dissolution, or other sale or transfer of some or all
|
||||
of Our assets, whether as a going concern or as part of bankruptcy, liquidation, or similar
|
||||
proceeding, in which Personal Data held by Us about our Service users is among the assets
|
||||
transferred.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "For other purposes:" }
|
||||
"""
|
||||
We may use Your information for other purposes, such as data analysis,
|
||||
identifying usage trends, determining the effectiveness of our promotional campaigns and to
|
||||
evaluate and improve our Service, products, services, marketing and your experience.
|
||||
"""
|
||||
}
|
||||
}
|
||||
p {
|
||||
"We may share Your Personal Data in the following situations:"
|
||||
}
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
span(.class("font-bold")) { "With Service Providers:" }
|
||||
"""
|
||||
We may share Your Personal Data with Service Providers to monitor and
|
||||
analyze the use of our Service, to contact You.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "For business transfers:" }
|
||||
"""
|
||||
We may share or transfer Your Personal Data in connection with, or
|
||||
during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a
|
||||
portion of Our business to another company.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "With Affiliates:" }
|
||||
"""
|
||||
We may share Your Personal Data with Our affiliates, in which case we will
|
||||
require those affiliates to honor this Privacy Policy. Affiliates include Our parent company and
|
||||
any other subsidiaries, joint venture partners or other companies that We control or that are
|
||||
under common control with Us.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "With business partners:" }
|
||||
"""
|
||||
We may share Your Personal Data with Our business partners to offer
|
||||
You certain products, services or promotions.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "With other users:" }
|
||||
"""
|
||||
If Our Service offers public areas, when You share Personal Data or
|
||||
otherwise interact in the public areas with other users, such information may be viewed by all
|
||||
users and may be publicly distributed outside.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
span(.class("font-bold")) { "With Your consent:" }
|
||||
"""
|
||||
We may disclose Your Personal Data for any other purpose with Your consent.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
h3(.class("text-2xl font-bold")) { "Retention of Your Personal Data" }
|
||||
p {
|
||||
"""
|
||||
The Company will retain Your Personal Data only for as long as is necessary for the purposes set out
|
||||
in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply
|
||||
with our legal obligations (for example, if We are required to retain Your data to comply with
|
||||
applicable laws), resolve disputes, and enforce our legal agreements and policies.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
Where possible, We apply shorter retention periods and/or reduce identifiability by deleting,
|
||||
aggregating, or anonymizing data. Unless otherwise stated, the retention periods below are maximum
|
||||
periods ("up to") and We may delete or anonymize data sooner when it is no longer needed for the
|
||||
relevant purpose. We apply different retention periods to different categories of Personal Data
|
||||
based on the purpose of processing and legal obligations:
|
||||
"""
|
||||
}
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
"Account Information"
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
"""
|
||||
User Accounts: retained for the duration of your account relationship plus up to 24 months
|
||||
after account closure to handle any post-termination issues or resolve disputes.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
li {
|
||||
"Customer Support Data"
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
"""
|
||||
Support tickets and correspondence: up to 24 months from the date of ticket closure to resolve
|
||||
follow-up inquiries, track service quality, and defend against potential legal claims.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
"""
|
||||
Chat transcripts: up to 24 months for quality assurance and staff training purposes.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
li {
|
||||
"Usage Data"
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
"""
|
||||
Website analytics data (cookies, IP addresses, device identifiers): up to 24 months from the
|
||||
date of collection, which allows us to analyze trends while respecting privacy principles.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
"""
|
||||
Server logs (IP addresses, access times): up to 24 months for security monitoring and
|
||||
troubleshooting purposes.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
p {
|
||||
"""
|
||||
Usage Data is retained in accordance with the retention periods described above, and may be retained
|
||||
longer only where necessary for security, fraud prevention, or legal compliance.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
We may retain Personal Data beyond the periods stated above for different reasons:
|
||||
"""
|
||||
}
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
"""
|
||||
Legal obligation: We are required by law to retain specific data (e.g., financial records for tax
|
||||
authorities).
|
||||
"""
|
||||
}
|
||||
li {
|
||||
"""
|
||||
Legal claims: Data is necessary to establish, exercise, or defend legal claims.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
"""
|
||||
Your explicit request: You ask Us to retain specific information.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
"""
|
||||
Technical limitations: Data exists in backup systems that are scheduled for routine deletion.
|
||||
"""
|
||||
}
|
||||
}
|
||||
p {
|
||||
"""
|
||||
You may request information about how long We will retain Your Personal Data by contacting Us.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
When retention periods expire, We securely delete or anonymize Personal Data according to the
|
||||
following procedures:
|
||||
"""
|
||||
}
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
"""
|
||||
Deletion: Personal Data is removed from Our systems and no longer actively processed.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
"""
|
||||
Backup retention: Residual copies may remain in encrypted backups for a limited period consistent
|
||||
with our backup retention schedule and are not restored except where necessary for security,
|
||||
disaster recovery, or legal compliance.
|
||||
"""
|
||||
}
|
||||
li {
|
||||
"""
|
||||
Anonymization: In some cases, We convert Personal Data into anonymous statistical data that cannot
|
||||
be linked back to You. This anonymized data may be retained indefinitely for research and
|
||||
analytics.
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
h3(.class("text-xl font-bold")) { "Transfer of Your Personal Data" }
|
||||
p {
|
||||
"""
|
||||
Your information, including Personal Data, is processed at the Company's operating offices and in
|
||||
any other places where the parties involved in the processing are located. It means that this
|
||||
information may be transferred to — and maintained on — computers located outside of Your state,
|
||||
province, country or other governmental jurisdiction where the data protection laws may differ from
|
||||
those from Your jurisdiction.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
Where required by applicable law, We will ensure that international transfers of Your Personal Data
|
||||
are subject to appropriate safeguards and supplementary measures where appropriate. The Company will
|
||||
take all steps reasonably necessary to ensure that Your data is treated securely and in accordance
|
||||
with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or
|
||||
a country unless there are adequate controls in place including the security of Your data and other
|
||||
personal information.
|
||||
"""
|
||||
}
|
||||
|
||||
h3(.class("text-xl font-bold")) { "Delete Your Personal Data" }
|
||||
p {
|
||||
"""
|
||||
You have the right to delete or request that We assist in deleting the Personal Data that We have
|
||||
collected about You.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
Our Service may give You the ability to delete certain information about You from within the
|
||||
Service.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
You may update, amend, or delete Your information at any time by signing in to Your Account, if you
|
||||
have one, and visiting the account settings section that allows you to manage Your personal
|
||||
information. You may also contact Us to request access to, correct, or delete any Personal Data that
|
||||
You have provided to Us.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
Please note, however, that We may need to retain certain information when we have a legal obligation
|
||||
or lawful basis to do so.
|
||||
"""
|
||||
}
|
||||
|
||||
h3(.class("text-xl font-bold")) { "Disclosure of Your Personal Data" }
|
||||
|
||||
h4(.class("text-lg font-bold")) { "Business Transactions" }
|
||||
p {
|
||||
"""
|
||||
If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be
|
||||
transferred. We will provide notice before Your Personal Data is transferred and becomes subject to
|
||||
a different Privacy Policy.
|
||||
"""
|
||||
}
|
||||
|
||||
h4(.class("text-lg font-bold")) { "Law enforcement" }
|
||||
p {
|
||||
"""
|
||||
Under certain circumstances, the Company may be required to disclose Your Personal Data if required
|
||||
to do so by law or in response to valid requests by public authorities (e.g. a court or a government
|
||||
agency).
|
||||
"""
|
||||
}
|
||||
|
||||
h4(.class("text-lg font-bold")) { "Other legal requirements" }
|
||||
p {
|
||||
"""
|
||||
The Company may disclose Your Personal Data in the good faith belief that such action is necessary
|
||||
to:
|
||||
"""
|
||||
}
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li { "Comply with a legal obligation" }
|
||||
li { "Protect and defend the rights or property of the Company" }
|
||||
li { "Prevent or investigate possible wrongdoing in connection with the Service" }
|
||||
li { "Protect the personal safety of Users of the Service or the public" }
|
||||
li { "Protect against legal liability" }
|
||||
}
|
||||
|
||||
h3(.class("text-xl font-bold")) { "Security of Your Personal Data" }
|
||||
p {
|
||||
"""
|
||||
The security of Your Personal Data is important to Us, but remember that no method of transmission
|
||||
over the Internet, or method of electronic storage is 100% secure. While We strive to use
|
||||
commercially reasonable means to protect Your Personal Data, We cannot guarantee its absolute
|
||||
security.
|
||||
"""
|
||||
}
|
||||
|
||||
h2(.class("text-2xl font-bold")) { "Children's Privacy" }
|
||||
p {
|
||||
"""
|
||||
Our Service does not address anyone under the age of 16. We do not knowingly collect personally
|
||||
identifiable information from anyone under the age of 16. If You are a parent or guardian and You
|
||||
are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware
|
||||
that We have collected Personal Data from anyone under the age of 16 without verification of
|
||||
parental consent, We take steps to remove that information from Our servers.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
If We need to rely on consent as a legal basis for processing Your information and Your country
|
||||
requires consent from a parent, We may require Your parent's consent before We collect and use that
|
||||
information.
|
||||
"""
|
||||
}
|
||||
|
||||
h2(.class("text-2xl font-bold")) { "Links to Other Websites" }
|
||||
p {
|
||||
"""
|
||||
Our Service may contain links to other websites that are not operated by Us. If You click on a third
|
||||
party link, You will be directed to that third party's site. We strongly advise You to review the
|
||||
Privacy Policy of every site You visit.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
We have no control over and assume no responsibility for the content, privacy policies or practices
|
||||
of any third party sites or services.
|
||||
"""
|
||||
}
|
||||
|
||||
h2(.class("text-2xl font-bold")) { "Changes to this Privacy Policy" }
|
||||
p {
|
||||
"""
|
||||
We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the
|
||||
new Privacy Policy on this page.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
We will let You know via email and/or a prominent notice on Our Service, prior to the change
|
||||
becoming effective and update the "Last updated" date at the top of this Privacy Policy.
|
||||
"""
|
||||
}
|
||||
p {
|
||||
"""
|
||||
You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy
|
||||
Policy are effective when they are posted on this page.
|
||||
"""
|
||||
}
|
||||
|
||||
h2(.class("text-2xl font-bold")) { "Contact Us" }
|
||||
p {
|
||||
"""
|
||||
If you have any questions about this Privacy Policy, You can contact us:
|
||||
"""
|
||||
}
|
||||
|
||||
ul(.class("list-disc mx-6 space-y-2")) {
|
||||
li {
|
||||
span { "By email: " }
|
||||
a(
|
||||
.href("mailto://support@ductcalc.pro"),
|
||||
.class("btn btn-link")
|
||||
) {
|
||||
"support@ductcalc.pro"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ struct ProjectView<Inner: HTML>: HTML, Sendable where Inner: Sendable {
|
||||
input(.id("my-drawer-1"), .type(.checkbox), .class("drawer-toggle"))
|
||||
|
||||
div(.class("drawer-content overflow-auto")) {
|
||||
Navbar(sidebarToggle: true)
|
||||
Navbar(showSidebarToggle: true)
|
||||
div(.class("p-4")) {
|
||||
inner
|
||||
.environment(ProjectViewValue.$projectID, projectID)
|
||||
@@ -190,25 +190,25 @@ extension ProjectView {
|
||||
div(.class("flex items-center justify-center")) {
|
||||
SVG(icon)
|
||||
}
|
||||
.attributes(.class("is-drawer-close:text-green-400"), when: isComplete)
|
||||
.attributes(.class("is-drawer-close:text-error"), when: !isComplete && !hideIsComplete)
|
||||
.attributes(.class("text-green-400"), when: isComplete)
|
||||
.attributes(.class("text-error"), when: !isComplete && !hideIsComplete)
|
||||
|
||||
div(.class("flex items-center justify-center")) {
|
||||
span { title }
|
||||
}
|
||||
}
|
||||
|
||||
if !hideIsComplete {
|
||||
div(.class("flex grow justify-end items-end is-drawer-close:hidden")) {
|
||||
if isComplete {
|
||||
SVG(.badgeCheck)
|
||||
} else {
|
||||
SVG(.ban)
|
||||
}
|
||||
}
|
||||
.attributes(.class("text-green-400"), when: isComplete)
|
||||
.attributes(.class("text-error"), when: !isComplete)
|
||||
}
|
||||
// if !hideIsComplete {
|
||||
// div(.class("flex grow justify-end items-end is-drawer-close:hidden")) {
|
||||
// if isComplete {
|
||||
// SVG(.badgeCheck)
|
||||
// } else {
|
||||
// SVG(.ban)
|
||||
// }
|
||||
// }
|
||||
// .attributes(.class("text-green-400"), when: isComplete)
|
||||
// .attributes(.class("text-error"), when: !isComplete)
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ struct ProjectsTable: HTML, Sendable {
|
||||
|
||||
var body: some HTML {
|
||||
div {
|
||||
Navbar(sidebarToggle: false)
|
||||
Navbar(showSidebarToggle: false)
|
||||
div(.class("m-6")) {
|
||||
PageTitleRow {
|
||||
PageTitle { "Projects" }
|
||||
|
||||
@@ -5,7 +5,6 @@ import Foundation
|
||||
import ManualDCore
|
||||
import Styleguide
|
||||
|
||||
// TODO: Need to hold the project ID in hidden input field.
|
||||
struct RoomForm: HTML, Sendable {
|
||||
|
||||
static func id(_ room: Room? = nil) -> String {
|
||||
@@ -17,27 +16,35 @@ struct RoomForm: HTML, Sendable {
|
||||
let dismiss: Bool
|
||||
let projectID: Project.ID
|
||||
let room: Room?
|
||||
let rooms: [Room]
|
||||
|
||||
init(
|
||||
dismiss: Bool,
|
||||
projectID: Project.ID,
|
||||
rooms: [Room],
|
||||
room: Room? = nil
|
||||
) {
|
||||
self.dismiss = dismiss
|
||||
self.projectID = projectID
|
||||
self.rooms = rooms
|
||||
self.room = room
|
||||
}
|
||||
|
||||
var route: String {
|
||||
private var route: String {
|
||||
SiteRoute.View.router.path(
|
||||
for: .project(.detail(projectID, .rooms(.index)))
|
||||
)
|
||||
.appendingPath(room?.id)
|
||||
}
|
||||
|
||||
private var selectableRooms: [Room] {
|
||||
rooms.filter { $0.delegatedTo == nil }
|
||||
}
|
||||
|
||||
var body: some HTML {
|
||||
ModalForm(id: Self.id(room), dismiss: dismiss) {
|
||||
h1(.class("text-3xl font-bold pb-6")) { "Room" }
|
||||
|
||||
form(
|
||||
.class("grid grid-cols-1 gap-4"),
|
||||
room == nil
|
||||
@@ -47,8 +54,6 @@ struct RoomForm: HTML, Sendable {
|
||||
.hx.swap(.outerHTML)
|
||||
) {
|
||||
|
||||
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
|
||||
|
||||
if let id = room?.id {
|
||||
input(.class("hidden"), .name("id"), .value("\(id)"))
|
||||
}
|
||||
@@ -63,6 +68,21 @@ struct RoomForm: HTML, Sendable {
|
||||
.value(room?.name)
|
||||
)
|
||||
|
||||
LabeledInput(
|
||||
"Level",
|
||||
.name("level"),
|
||||
.type(.number),
|
||||
.placeholder("1 (Optional)"),
|
||||
.value(room?.level?.rawValue),
|
||||
.min("-1"),
|
||||
.step("1")
|
||||
)
|
||||
div(.class("text-sm italic -mt-2")) {
|
||||
span(.class("text-primary")) {
|
||||
"Use -1 or 0 for a basement"
|
||||
}
|
||||
}
|
||||
|
||||
LabeledInput(
|
||||
"Heating Load",
|
||||
.name("heatingLoad"),
|
||||
@@ -77,10 +97,9 @@ struct RoomForm: HTML, Sendable {
|
||||
"Cooling Total",
|
||||
.name("coolingTotal"),
|
||||
.type(.number),
|
||||
.placeholder("1234"),
|
||||
.required,
|
||||
.placeholder("1234 (Optional)"),
|
||||
.min("0"),
|
||||
.value(room?.coolingTotal)
|
||||
.value(room?.coolingLoad.total)
|
||||
)
|
||||
|
||||
LabeledInput(
|
||||
@@ -89,8 +108,16 @@ struct RoomForm: HTML, Sendable {
|
||||
.type(.number),
|
||||
.placeholder("1234 (Optional)"),
|
||||
.min("0"),
|
||||
.value(room?.coolingSensible)
|
||||
.value(room?.coolingLoad.sensible)
|
||||
)
|
||||
div(.class("text-primary text-sm italic -mt-2")) {
|
||||
p {
|
||||
"Should enter at least one of the cooling loads."
|
||||
}
|
||||
p {
|
||||
"Both are also acceptable."
|
||||
}
|
||||
}
|
||||
|
||||
LabeledInput(
|
||||
"Registers",
|
||||
@@ -98,9 +125,16 @@ struct RoomForm: HTML, Sendable {
|
||||
.type(.number),
|
||||
.min("1"),
|
||||
.required,
|
||||
.value(room?.registerCount ?? 1)
|
||||
.value(room?.registerCount ?? 1),
|
||||
.id("registerCount")
|
||||
)
|
||||
|
||||
label(.class("select w-full")) {
|
||||
span(.class("label")) { "Room" }
|
||||
Select(selectableRooms, label: \.name, placeholder: "Delegate Airflow")
|
||||
.attributes(.name("delegatedTo"))
|
||||
}
|
||||
|
||||
SubmitButton()
|
||||
.attributes(.class("btn-block"))
|
||||
}
|
||||
|
||||
@@ -7,10 +7,22 @@ import Styleguide
|
||||
|
||||
struct RoomsView: HTML, Sendable {
|
||||
@Environment(ProjectViewValue.$projectID) var projectID
|
||||
// let projectID: Project.ID
|
||||
let rooms: [Room]
|
||||
let sensibleHeatRatio: Double?
|
||||
|
||||
private var csvRoute: String {
|
||||
SiteRoute.router.path(for: .view(.project(.detail(projectID, .rooms(.index)))))
|
||||
.appendingPath("csv")
|
||||
}
|
||||
|
||||
// Sort the rooms based on level, they should already be sorted by name,
|
||||
// so this puts lower level rooms towards the top in alphabetical order.
|
||||
//
|
||||
// If rooms do not have a level we shove those all the way to the bottom.
|
||||
private var sortedRooms: [Room] {
|
||||
rooms.sorted { ($0.level?.rawValue ?? 20) < ($1.level?.rawValue ?? 20) }
|
||||
}
|
||||
|
||||
var body: some HTML {
|
||||
div(.class("flex w-full flex-col")) {
|
||||
PageTitleRow {
|
||||
@@ -20,7 +32,7 @@ struct RoomsView: HTML, Sendable {
|
||||
PageTitle { "Room Loads" }
|
||||
}
|
||||
|
||||
div(.class("flex justify-end grow")) {
|
||||
div(.class("flex justify-end grow space-x-4")) {
|
||||
Tooltip("Set sensible heat ratio", position: .left) {
|
||||
button(
|
||||
.class(
|
||||
@@ -44,6 +56,7 @@ struct RoomsView: HTML, Sendable {
|
||||
.attributes(.class("border border-error"), when: sensibleHeatRatio == nil)
|
||||
}
|
||||
.attributes(.class("tooltip-open"), when: sensibleHeatRatio == nil)
|
||||
|
||||
}
|
||||
|
||||
div(.class("flex items-end space-x-4 font-bold")) {
|
||||
@@ -54,13 +67,15 @@ struct RoomsView: HTML, Sendable {
|
||||
|
||||
div(.class("flex justify-center items-end space-x-4 my-auto font-bold")) {
|
||||
span(.class("text-lg")) { "Cooling Total" }
|
||||
Badge(number: rooms.totalCoolingLoad, digits: 0)
|
||||
// TODO: ResultView ??
|
||||
Badge(number: try! rooms.totalCoolingLoad(shr: sensibleHeatRatio ?? 1.0), digits: 0)
|
||||
.attributes(.class("badge-success"))
|
||||
}
|
||||
|
||||
div(.class("flex grow justify-end items-end space-x-4 me-4 my-auto font-bold")) {
|
||||
span(.class("text-lg")) { "Cooling Sensible" }
|
||||
Badge(number: rooms.totalCoolingSensible(shr: sensibleHeatRatio ?? 1.0), digits: 0)
|
||||
// TODO: ResultView ??
|
||||
Badge(number: try! rooms.totalCoolingSensible(shr: sensibleHeatRatio ?? 1.0), digits: 0)
|
||||
.attributes(.class("badge-info"))
|
||||
}
|
||||
}
|
||||
@@ -96,7 +111,22 @@ struct RoomsView: HTML, Sendable {
|
||||
}
|
||||
}
|
||||
th {
|
||||
div(.class("flex justify-end me-2")) {
|
||||
div(.class("flex justify-center")) {
|
||||
"Delegated To"
|
||||
}
|
||||
}
|
||||
th {
|
||||
div(.class("flex justify-end me-2 space-x-4")) {
|
||||
|
||||
Tooltip("Upload CSV", position: .left) {
|
||||
button(
|
||||
.class("btn btn-secondary"),
|
||||
.showModal(id: UploadCSVForm.id)
|
||||
) {
|
||||
SVG(.filePlusCorner)
|
||||
}
|
||||
}
|
||||
|
||||
Tooltip("Add Room") {
|
||||
PlusButton()
|
||||
.attributes(
|
||||
@@ -105,40 +135,52 @@ struct RoomsView: HTML, Sendable {
|
||||
)
|
||||
.attributes(.class("tooltip-left"))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
tbody {
|
||||
for room in rooms {
|
||||
RoomRow(room: room, shr: sensibleHeatRatio)
|
||||
for room in sortedRooms {
|
||||
RoomRow(room: room, shr: sensibleHeatRatio, rooms: rooms)
|
||||
}
|
||||
}
|
||||
}
|
||||
RoomForm(dismiss: true, projectID: projectID, room: nil)
|
||||
RoomForm(dismiss: true, projectID: projectID, rooms: rooms, room: nil)
|
||||
UploadCSVForm(dismiss: true)
|
||||
}
|
||||
}
|
||||
|
||||
public struct RoomRow: HTML, Sendable {
|
||||
|
||||
let rooms: [Room]
|
||||
let room: Room
|
||||
let shr: Double
|
||||
|
||||
var coolingSensible: Double {
|
||||
guard let value = room.coolingSensible else {
|
||||
return room.coolingTotal * shr
|
||||
}
|
||||
return value
|
||||
try! room.coolingLoad.ensured(shr: shr).sensible
|
||||
}
|
||||
|
||||
init(room: Room, shr: Double?) {
|
||||
var delegatedToRoomName: String? {
|
||||
guard let delegatedToID = room.delegatedTo else { return nil }
|
||||
return rooms.first(where: { $0.id == delegatedToID })?.name
|
||||
}
|
||||
|
||||
init(room: Room, shr: Double?, rooms: [Room]) {
|
||||
self.room = room
|
||||
self.shr = shr ?? 1.0
|
||||
self.rooms = rooms
|
||||
}
|
||||
|
||||
public var body: some HTML {
|
||||
tr(.id("roomRow_\(room.id.idString)")) {
|
||||
td { room.name }
|
||||
td {
|
||||
if let level = room.level {
|
||||
"\(level.label) - \(room.name)"
|
||||
} else {
|
||||
room.name
|
||||
}
|
||||
}
|
||||
td {
|
||||
div(.class("flex justify-center")) {
|
||||
Number(room.heatingLoad, digits: 0)
|
||||
@@ -147,7 +189,7 @@ struct RoomsView: HTML, Sendable {
|
||||
}
|
||||
td {
|
||||
div(.class("flex justify-center")) {
|
||||
Number(room.coolingTotal, digits: 0)
|
||||
Number(try! room.coolingLoad.ensured(shr: shr).total, digits: 0)
|
||||
// .attributes(.class("text-success"))
|
||||
}
|
||||
}
|
||||
@@ -159,7 +201,14 @@ struct RoomsView: HTML, Sendable {
|
||||
}
|
||||
td {
|
||||
div(.class("flex justify-center")) {
|
||||
Number(room.registerCount)
|
||||
Number(delegatedToRoomName != nil ? 0 : room.registerCount)
|
||||
}
|
||||
}
|
||||
td {
|
||||
if let name = delegatedToRoomName {
|
||||
div(.class("flex justify-center")) {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
td {
|
||||
@@ -188,6 +237,7 @@ struct RoomsView: HTML, Sendable {
|
||||
RoomForm(
|
||||
dismiss: true,
|
||||
projectID: room.projectID,
|
||||
rooms: rooms,
|
||||
room: room
|
||||
)
|
||||
}
|
||||
@@ -237,4 +287,39 @@ struct RoomsView: HTML, Sendable {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct UploadCSVForm: HTML {
|
||||
static let id = "uploadCSV"
|
||||
|
||||
@Environment(ProjectViewValue.$projectID) var projectID
|
||||
let dismiss: Bool
|
||||
|
||||
private var route: String {
|
||||
SiteRoute.router.path(for: .view(.project(.detail(projectID, .rooms(.index)))))
|
||||
.appendingPath("csv")
|
||||
}
|
||||
|
||||
var body: some HTML {
|
||||
ModalForm(id: Self.id, dismiss: dismiss) {
|
||||
div(.class("pb-6 space-y-3")) {
|
||||
h1(.class("text-3xl font-bold")) { "Upload CSV" }
|
||||
p(.class("text-sm italic")) {
|
||||
"Drag and drop, or click to upload"
|
||||
}
|
||||
}
|
||||
form(
|
||||
.hx.post(route),
|
||||
.hx.target("body"),
|
||||
.hx.swap(.outerHTML),
|
||||
.custom(name: "enctype", value: "multipart/form-data")
|
||||
) {
|
||||
input(.type(.file), .name("file"), .accept(".csv"))
|
||||
|
||||
SubmitButton()
|
||||
.attributes(.class("btn-block mt-6"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Dependencies
|
||||
import Elementary
|
||||
import Foundation
|
||||
import ManualDClient
|
||||
import ManualDCore
|
||||
import Styleguide
|
||||
|
||||
@@ -8,24 +9,77 @@ struct TestPage: HTML, Sendable {
|
||||
// let ductSizes: DuctSizes
|
||||
|
||||
var body: some HTML {
|
||||
div {}
|
||||
// div(.class("overflow-auto")) {
|
||||
// DuctSizingView.TrunkTable(ductSizes: ductSizes)
|
||||
//
|
||||
// Row {
|
||||
// h2(.class("text-2xl font-bold")) { "Trunk Sizes" }
|
||||
//
|
||||
// PlusButton()
|
||||
// .attributes(
|
||||
// .class("me-6"),
|
||||
// .showModal(id: TrunkSizeForm.id())
|
||||
// )
|
||||
// }
|
||||
// .attributes(.class("mt-6"))
|
||||
//
|
||||
// div(.class("divider -mt-2")) {}
|
||||
//
|
||||
// DuctSizingView.TrunkTable(ductSizes: ductSizes)
|
||||
// }
|
||||
div {
|
||||
Navbar(showSidebarToggle: false, isLoggedIn: false)
|
||||
div(.class("flex justify-center items-center px-10")) {
|
||||
div(
|
||||
.class(
|
||||
"""
|
||||
bg-base-300 rounded-3xl shadow-3xl
|
||||
p-6 w-full
|
||||
"""
|
||||
)
|
||||
) {
|
||||
div(.class("flex space-x-6 items-center text-4xl")) {
|
||||
SVG(.calculator)
|
||||
h1(.class("text-4xl font-bold me-10")) {
|
||||
"Duct Size"
|
||||
}
|
||||
}
|
||||
|
||||
p(.class("text-primary font-bold italic")) {
|
||||
"Calculate duct size for the given parameters"
|
||||
}
|
||||
|
||||
form(
|
||||
.class("space-y-4 mt-6"),
|
||||
.action("#")
|
||||
) {
|
||||
LabeledInput(
|
||||
"CFM",
|
||||
.required,
|
||||
.type(.number),
|
||||
.placeholder("1000"),
|
||||
.name("cfm")
|
||||
)
|
||||
|
||||
LabeledInput(
|
||||
"Friction Rate",
|
||||
.value("0.06"),
|
||||
.required,
|
||||
.type(.number),
|
||||
.name("frictionRate")
|
||||
)
|
||||
|
||||
LabeledInput(
|
||||
"Height",
|
||||
.required,
|
||||
.type(.number),
|
||||
.placeholder("Height (Optional)"),
|
||||
.name("frictionRate")
|
||||
)
|
||||
|
||||
SubmitButton()
|
||||
.attributes(.class("btn-block mt-6"))
|
||||
}
|
||||
}
|
||||
|
||||
// Populate when submitted
|
||||
div(.id(Result.id)) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Result: HTML, Sendable {
|
||||
static let id = "resultView"
|
||||
|
||||
let ductSize: ManualDClient.DuctSize
|
||||
let rectangularSize: ManualDClient.RectangularSize?
|
||||
|
||||
var body: some HTML<HTMLTag.div> {
|
||||
div(.id(Self.id)) {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Elementary
|
||||
import ElementaryHTMX
|
||||
import ManualDCore
|
||||
import Styleguide
|
||||
|
||||
struct LoginForm: HTML, Sendable {
|
||||
@@ -12,9 +13,25 @@ struct LoginForm: HTML, Sendable {
|
||||
self.next = next
|
||||
}
|
||||
|
||||
private var route: SiteRoute.View {
|
||||
if style == .login {
|
||||
return .login(.index(next: next))
|
||||
}
|
||||
return .signup(.index)
|
||||
}
|
||||
|
||||
var body: some HTML {
|
||||
ModalForm(id: "loginForm", closeButton: false, dismiss: false) {
|
||||
h1(.class("text-2xl font-bold mb-6")) { style.title }
|
||||
Row {
|
||||
h1(.class("text-2xl font-bold mb-6")) { style.title }
|
||||
a(
|
||||
.class("btn btn-link"),
|
||||
.href(route: .privacyPolicy),
|
||||
.target(.blank)
|
||||
) {
|
||||
"Privacy Policy"
|
||||
}
|
||||
}
|
||||
|
||||
form(
|
||||
.method(.post),
|
||||
@@ -70,6 +87,7 @@ struct LoginForm: HTML, Sendable {
|
||||
"At least one uppercase letter"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
div(.class("flex")) {
|
||||
|
||||
@@ -150,6 +150,10 @@ struct UserProfileForm: HTML, Sendable {
|
||||
.attributes(.class("btn-block"))
|
||||
|
||||
}
|
||||
.attributes(
|
||||
.hx.pushURL("/projects"),
|
||||
when: signup == true
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ struct UserView: HTML, Sendable {
|
||||
|
||||
var body: some HTML {
|
||||
div {
|
||||
Navbar(sidebarToggle: false, userProfile: false)
|
||||
Navbar(showSidebarToggle: false, isLoggedIn: false)
|
||||
|
||||
div(.class("p-4")) {
|
||||
Row {
|
||||
|
||||
27
TODO.md
27
TODO.md
@@ -2,8 +2,25 @@
|
||||
|
||||
- [x] Fix theme not working when selected upon signup.
|
||||
- [x] Pdf generation
|
||||
- [ ] Add postgres / mysql support
|
||||
- [ ] Opensource / license ??
|
||||
- [ ] Figure out domain to host (currently thinking ductcalc.pro)
|
||||
- [ ] Logo / navbar name may have to change if it's not duct-calc.
|
||||
- [ ] MainPage meta items will have to change also
|
||||
- [x] Add postgres / mysql support
|
||||
- [x] Opensource / license
|
||||
- [x] Figure out domain to host
|
||||
- [x] Add ability for either sensible or total load while specifying a room load.
|
||||
- CoolCalc current version specifies the sensible cooling for a room break down,
|
||||
and currently we require the total load and calculate sensible based on project
|
||||
shr.
|
||||
- [x] Add ability to associate room load / airflow with another room.
|
||||
- [x] Trunk size form, room / register selection is wonky when labels are long.
|
||||
- They will overlap each other making it difficult to read / decipher which checkbox belongs
|
||||
to which label.
|
||||
- [x] Add select all rooms for trunks, useful for sizing main supply or return trunks.
|
||||
- [x] Add optional level to room model.
|
||||
- [ ] Add way to sponsor the project.
|
||||
- [x] Need to push url after signup.
|
||||
- [x] Update urls to point to public mirror of repository
|
||||
- [x] Add email to footer
|
||||
- [x] Validation errors, if they occur are vague in ResultView
|
||||
- [x] Privacy policy
|
||||
- [ ] Update README
|
||||
- [ ] Self hosting documentation
|
||||
- [x] Check signup flow when using 'get-started' button from home page, it may need a push url.
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Testing
|
||||
import URLRouting
|
||||
|
||||
@Suite("ProjectRouteTests")
|
||||
struct ProjectRouteTests {
|
||||
let router = SiteRoute.Api.router
|
||||
|
||||
@Test
|
||||
func create() throws {
|
||||
let json = """
|
||||
{
|
||||
\"name\": \"Test\",
|
||||
\"streetAddress\": \"1234 Seasme Street\",
|
||||
\"city\": \"Nowhere\",
|
||||
\"state\": \"OH\",
|
||||
\"zipCode\": \"55555\"
|
||||
}
|
||||
"""
|
||||
var request = URLRequestData(
|
||||
method: "POST",
|
||||
path: "/api/v1/projects",
|
||||
body: .init(json.utf8)
|
||||
)
|
||||
let route = try router.parse(&request)
|
||||
#expect(
|
||||
route
|
||||
== .project(
|
||||
.create(
|
||||
.init(
|
||||
name: "Test",
|
||||
streetAddress: "1234 Seasme Street",
|
||||
city: "Nowhere",
|
||||
state: "OH",
|
||||
zipCode: "55555"
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
func delete() throws {
|
||||
let id = UUID(0)
|
||||
var request = URLRequestData(
|
||||
method: "DELETE",
|
||||
path: "/api/v1/projects/\(id)"
|
||||
)
|
||||
let route = try router.parse(&request)
|
||||
#expect(route == .project(.delete(id: id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func get() throws {
|
||||
let id = UUID(0)
|
||||
var request = URLRequestData(
|
||||
method: "GET",
|
||||
path: "/api/v1/projects/\(id)"
|
||||
)
|
||||
let route = try router.parse(&request)
|
||||
#expect(route == .project(.get(id: id)))
|
||||
}
|
||||
|
||||
@Test
|
||||
func index() throws {
|
||||
var request = URLRequestData(
|
||||
method: "GET",
|
||||
path: "/api/v1/projects"
|
||||
)
|
||||
let route = try router.parse(&request)
|
||||
#expect(route == .project(.index))
|
||||
}
|
||||
|
||||
@Test
|
||||
func formData() throws {
|
||||
let p = Body {
|
||||
FormData {
|
||||
Optionally {
|
||||
Field("id", default: nil) { EquivalentLength.ID.parser() }
|
||||
}
|
||||
Field("name", .string)
|
||||
Field("type") { EquivalentLength.EffectiveLengthType.parser() }
|
||||
Many {
|
||||
Field("straightLengths") {
|
||||
Int.parser()
|
||||
}
|
||||
}
|
||||
}
|
||||
.map(.memberwise(SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo.init))
|
||||
}
|
||||
|
||||
var request = URLRequestData(
|
||||
body: .init(
|
||||
"name=Test&type=supply&straightLengths=20&straightLengths=10"
|
||||
.utf8
|
||||
)
|
||||
)
|
||||
let value = try p.parse(&request)
|
||||
print(value)
|
||||
#expect(value.straightLengths == [20, 10])
|
||||
}
|
||||
}
|
||||
25
Tests/CSVParsingTests/CSVParsingTests.swift
Normal file
25
Tests/CSVParsingTests/CSVParsingTests.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import CSVParser
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@Suite
|
||||
struct CSVParsingTests {
|
||||
|
||||
@Test
|
||||
func roomParsing() async throws {
|
||||
|
||||
let parser = CSVParser.liveValue
|
||||
|
||||
let input = """
|
||||
Name,Level,Heating Load,Cooling Total,Cooling Sensible,Register Count,Delegated To
|
||||
Bed-1,2,12345,2345,2345,2,
|
||||
Entry,1,3456,1234,990,1,
|
||||
Kitchen,1,6789,3456,,2,
|
||||
Bath-1,1,890,,345,0,Kitchen
|
||||
"""
|
||||
let rooms = try await parser.parseRooms(.init(file: Data(input.utf8)))
|
||||
|
||||
#expect(rooms.count == 4)
|
||||
#expect(rooms.first!.level == 2)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Testing
|
||||
|
||||
@testable import DatabaseClient
|
||||
|
||||
@Suite
|
||||
struct ComponentLossTests {
|
||||
|
||||
@@ -50,4 +51,18 @@ struct ComponentLossTests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
ComponentLossModel(name: "", value: 0.2, projectID: UUID(0)),
|
||||
ComponentLossModel(name: "Foo", value: -0.2, projectID: UUID(0)),
|
||||
ComponentLossModel(name: "Foo", value: 1.2, projectID: UUID(0)),
|
||||
ComponentLossModel(name: "", value: -0.2, projectID: UUID(0)),
|
||||
]
|
||||
)
|
||||
func validations(model: ComponentLossModel) {
|
||||
#expect(throws: (any Error).self) {
|
||||
try model.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Testing
|
||||
|
||||
@testable import DatabaseClient
|
||||
|
||||
@Suite
|
||||
struct EquipmentTests {
|
||||
|
||||
@@ -51,4 +52,18 @@ struct EquipmentTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
EquipmentModel(staticPressure: -1, heatingCFM: 1000, coolingCFM: 1000, projectID: UUID(0)),
|
||||
EquipmentModel(staticPressure: 0.5, heatingCFM: -1, coolingCFM: 1000, projectID: UUID(0)),
|
||||
EquipmentModel(staticPressure: 0.5, heatingCFM: 1000, coolingCFM: -1000, projectID: UUID(0)),
|
||||
EquipmentModel(staticPressure: 1.1, heatingCFM: 1000, coolingCFM: -1000, projectID: UUID(0)),
|
||||
]
|
||||
)
|
||||
func validations(model: EquipmentModel) {
|
||||
#expect(throws: (any Error).self) {
|
||||
try model.validate()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
123
Tests/DatabaseClientTests/EquivalentLengthTests.swift
Normal file
123
Tests/DatabaseClientTests/EquivalentLengthTests.swift
Normal file
@@ -0,0 +1,123 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Testing
|
||||
|
||||
@testable import DatabaseClient
|
||||
|
||||
@Suite
|
||||
struct EquivalentLengthTests {
|
||||
|
||||
@Test
|
||||
func happyPath() async throws {
|
||||
try await withTestUserAndProject { user, project in
|
||||
@Dependency(\.database.equivalentLengths) var equivalentLengths
|
||||
|
||||
let equivalentLength = try await equivalentLengths.create(
|
||||
.init(
|
||||
projectID: project.id,
|
||||
name: "Test",
|
||||
type: .supply,
|
||||
straightLengths: [10],
|
||||
groups: [
|
||||
.init(group: 1, letter: "a", value: 20),
|
||||
.init(group: 2, letter: "a", value: 30, quantity: 2),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
let fetched = try await equivalentLengths.fetch(project.id)
|
||||
#expect(fetched == [equivalentLength])
|
||||
|
||||
let got = try await equivalentLengths.get(equivalentLength.id)
|
||||
#expect(got == equivalentLength)
|
||||
|
||||
var max = try await equivalentLengths.fetchMax(project.id)
|
||||
#expect(max.supply == equivalentLength)
|
||||
#expect(max.return == nil)
|
||||
|
||||
let returnLength = try await equivalentLengths.create(
|
||||
.init(
|
||||
projectID: project.id,
|
||||
name: "Test",
|
||||
type: .return,
|
||||
straightLengths: [10],
|
||||
groups: [
|
||||
.init(group: 1, letter: "a", value: 20),
|
||||
.init(group: 2, letter: "a", value: 30, quantity: 2),
|
||||
]
|
||||
)
|
||||
)
|
||||
max = try await equivalentLengths.fetchMax(project.id)
|
||||
#expect(max.supply == equivalentLength)
|
||||
#expect(max.return == returnLength)
|
||||
|
||||
let updated = try await equivalentLengths.update(
|
||||
equivalentLength.id, .init(name: "Supply Test")
|
||||
)
|
||||
#expect(updated.name == "Supply Test")
|
||||
#expect(updated.id == equivalentLength.id)
|
||||
|
||||
try await equivalentLengths.delete(equivalentLength.id)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func notFound() async throws {
|
||||
try await withDatabase {
|
||||
@Dependency(\.database.equivalentLengths) var equivalentLengths
|
||||
|
||||
await #expect(throws: NotFoundError.self) {
|
||||
try await equivalentLengths.delete(UUID(0))
|
||||
}
|
||||
|
||||
await #expect(throws: NotFoundError.self) {
|
||||
try await equivalentLengths.update(UUID(0), .init())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
EquivalentLength.Create(
|
||||
projectID: UUID(0), name: "", type: .return, straightLengths: [], groups: []
|
||||
),
|
||||
EquivalentLength.Create(
|
||||
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [-1, 1], groups: []
|
||||
),
|
||||
EquivalentLength.Create(
|
||||
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, -1], groups: []
|
||||
),
|
||||
EquivalentLength.Create(
|
||||
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
|
||||
groups: [
|
||||
.init(group: -1, letter: "a", value: 1.0, quantity: 1)
|
||||
]
|
||||
),
|
||||
EquivalentLength.Create(
|
||||
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
|
||||
groups: [
|
||||
.init(group: 1, letter: "1", value: 1.0, quantity: 1)
|
||||
]
|
||||
),
|
||||
EquivalentLength.Create(
|
||||
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
|
||||
groups: [
|
||||
.init(group: 1, letter: "a", value: -1.0, quantity: 1)
|
||||
]
|
||||
),
|
||||
EquivalentLength.Create(
|
||||
projectID: UUID(0), name: "Testy", type: .return, straightLengths: [1, 1],
|
||||
groups: [
|
||||
.init(group: 1, letter: "a", value: 1.0, quantity: -1)
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
func validations(model: EquivalentLength.Create) {
|
||||
#expect(throws: (any Error).self) {
|
||||
try model.toModel().validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import App
|
||||
import CSVParser
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import Fluent
|
||||
@@ -15,7 +16,7 @@ func withDatabase(
|
||||
) async throws {
|
||||
let app = try await Application.make(.testing)
|
||||
do {
|
||||
try await configure(app)
|
||||
try await configure(app, in: .live())
|
||||
let database = app.db
|
||||
try await app.autoMigrate()
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import Dependencies
|
||||
import DependenciesTestSupport
|
||||
import Fluent
|
||||
import FluentSQLiteDriver
|
||||
import ManualDCore
|
||||
@@ -86,7 +85,8 @@ struct ProjectTests {
|
||||
#expect(completed.frictionRate == true)
|
||||
|
||||
_ = try await database.rooms.create(
|
||||
.init(projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345)
|
||||
project.id,
|
||||
.init(name: "Test", heatingLoad: 12345, coolingTotal: 12345)
|
||||
)
|
||||
completed = try await database.projects.getCompletedSteps(project.id)
|
||||
#expect(completed.rooms == true)
|
||||
@@ -131,7 +131,8 @@ struct ProjectTests {
|
||||
.init(projectID: project.id, name: "Test", value: 0.2)
|
||||
)
|
||||
let room = try await database.rooms.create(
|
||||
.init(projectID: project.id, name: "Test", heatingLoad: 12345, coolingTotal: 12345)
|
||||
project.id,
|
||||
.init(name: "Test", heatingLoad: 12345, coolingTotal: 12345)
|
||||
)
|
||||
let supplyLength = try await database.equivalentLengths.create(
|
||||
.init(
|
||||
@@ -151,4 +152,55 @@ struct ProjectTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
ProjectModel(
|
||||
name: "", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH", zipCode: "55555",
|
||||
sensibleHeatRatio: nil, userID: UUID(0)
|
||||
),
|
||||
ProjectModel(
|
||||
name: "Testy", streetAddress: "", city: "Nowhere", state: "OH", zipCode: "55555",
|
||||
sensibleHeatRatio: nil, userID: UUID(0)
|
||||
),
|
||||
ProjectModel(
|
||||
name: "Testy", streetAddress: "1234 Sesame St", city: "", state: "OH", zipCode: "55555",
|
||||
sensibleHeatRatio: nil, userID: UUID(0)
|
||||
),
|
||||
ProjectModel(
|
||||
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "",
|
||||
zipCode: "55555",
|
||||
sensibleHeatRatio: nil, userID: UUID(0)
|
||||
),
|
||||
ProjectModel(
|
||||
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH",
|
||||
zipCode: "",
|
||||
sensibleHeatRatio: nil, userID: UUID(0)
|
||||
),
|
||||
ProjectModel(
|
||||
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH",
|
||||
zipCode: "55555",
|
||||
sensibleHeatRatio: -1, userID: UUID(0)
|
||||
),
|
||||
ProjectModel(
|
||||
name: "Testy", streetAddress: "1234 Sesame St", city: "Nowhere", state: "OH",
|
||||
zipCode: "55555",
|
||||
sensibleHeatRatio: 1.1, userID: UUID(0)
|
||||
),
|
||||
]
|
||||
)
|
||||
func validations(model: ProjectModel) {
|
||||
var errors = [String]()
|
||||
|
||||
#expect(throws: (any Error).self) {
|
||||
do {
|
||||
try model.validate()
|
||||
} catch {
|
||||
// Just checking to make sure I'm not testing the same error over and over /
|
||||
// making sure I've reset to good values / only testing one property at a time.
|
||||
#expect(!errors.contains("\(error)"))
|
||||
errors.append("\(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
Tests/DatabaseClientTests/Resources/rooms.csv
Normal file
5
Tests/DatabaseClientTests/Resources/rooms.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
Name,Level,Heating Load,Cooling Total,Cooling Sensible,Register Count,Delegated To
|
||||
Bed-1,2,2345,1234,1321,1,
|
||||
Entry,1,3456,2345,1234,1,
|
||||
Kitchen,1,7654,3456,2453,2,
|
||||
Bath-1,1,890,345,,0,Kitchen
|
||||
|
183
Tests/DatabaseClientTests/RoomTests.swift
Normal file
183
Tests/DatabaseClientTests/RoomTests.swift
Normal file
@@ -0,0 +1,183 @@
|
||||
import CSVParser
|
||||
import Dependencies
|
||||
import FileClient
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Parsing
|
||||
import Testing
|
||||
|
||||
@testable import DatabaseClient
|
||||
|
||||
@Suite
|
||||
struct RoomTests {
|
||||
|
||||
@Test
|
||||
func happyPath() async throws {
|
||||
try await withTestUserAndProject { _, project in
|
||||
@Dependency(\.database.rooms) var rooms
|
||||
|
||||
let room = try await rooms.create(
|
||||
project.id,
|
||||
.init(name: "Test", heatingLoad: 1234, coolingTotal: 1234)
|
||||
)
|
||||
|
||||
let fetched = try await rooms.fetch(project.id)
|
||||
#expect(fetched == [room])
|
||||
|
||||
let got = try await rooms.get(room.id)
|
||||
#expect(got == room)
|
||||
|
||||
let updated = try await rooms.update(
|
||||
room.id,
|
||||
.init(rectangularSizes: [.init(id: UUID(0), register: 1, height: 8)])
|
||||
)
|
||||
#expect(updated.id == room.id)
|
||||
|
||||
let updatedSize = try await rooms.updateRectangularSize(
|
||||
room.id, .init(id: UUID(0), register: 1, height: 10)
|
||||
)
|
||||
#expect(updatedSize.id == room.id)
|
||||
|
||||
let deletedSize = try await rooms.deleteRectangularSize(room.id, UUID(0))
|
||||
#expect(deletedSize.rectangularSizes == nil)
|
||||
|
||||
try await rooms.delete(room.id)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func createMany() async throws {
|
||||
try await withTestUserAndProject { _, project in
|
||||
@Dependency(\.database.rooms) var rooms
|
||||
|
||||
let created = try await rooms.createMany(
|
||||
project.id,
|
||||
[
|
||||
.init(name: "Test 1", heatingLoad: 1234, coolingTotal: 1234),
|
||||
.init(name: "Test 2", heatingLoad: 1234, coolingTotal: 1234),
|
||||
]
|
||||
)
|
||||
|
||||
#expect(created.count == 2)
|
||||
#expect(created[0].name == "Test 1")
|
||||
#expect(created[1].name == "Test 2")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func createFromCSV() async throws {
|
||||
try await withTestUserAndProject {
|
||||
$0.csvParser = .liveValue
|
||||
} operation: { _, project in
|
||||
@Dependency(\.csvParser) var csvParser
|
||||
@Dependency(\.database) var database
|
||||
@Dependency(\.fileClient) var fileClient
|
||||
|
||||
let csvPath = Bundle.module.path(forResource: "rooms", ofType: "csv")
|
||||
let csvFile = Room.CSV(file: try Data(contentsOf: URL(filePath: csvPath!)))
|
||||
let rows = try await csvParser.parseRooms(csvFile)
|
||||
let created = try await database.rooms.createFromCSV(project.id, rows)
|
||||
#expect(created.count == rows.count)
|
||||
|
||||
// Check that delegating to another room works properly.
|
||||
let bath = created.first(where: { $0.name == "Bath-1" })!
|
||||
let kitchen = created.first(where: { $0.name == "Kitchen" })!
|
||||
#expect(bath.delegatedTo == kitchen.id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func notFound() async throws {
|
||||
try await withDatabase {
|
||||
@Dependency(\.database.rooms) var rooms
|
||||
|
||||
await #expect(throws: NotFoundError.self) {
|
||||
try await rooms.delete(UUID(0))
|
||||
}
|
||||
|
||||
await #expect(throws: NotFoundError.self) {
|
||||
try await rooms.deleteRectangularSize(UUID(0), UUID(1))
|
||||
}
|
||||
|
||||
await #expect(throws: NotFoundError.self) {
|
||||
try await rooms.update(UUID(0), .init())
|
||||
}
|
||||
|
||||
await #expect(throws: NotFoundError.self) {
|
||||
try await rooms.updateRectangularSize(UUID(0), .init(height: 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
Room.Create(
|
||||
// projectID: UUID(0),
|
||||
name: "",
|
||||
heatingLoad: 12345,
|
||||
coolingTotal: 12344,
|
||||
coolingSensible: nil,
|
||||
registerCount: 1
|
||||
),
|
||||
Room.Create(
|
||||
// projectID: UUID(0),
|
||||
name: "Test",
|
||||
heatingLoad: -12345,
|
||||
coolingTotal: 12344,
|
||||
coolingSensible: nil,
|
||||
registerCount: 1
|
||||
),
|
||||
Room.Create(
|
||||
// projectID: UUID(0),
|
||||
name: "Test",
|
||||
heatingLoad: 12345,
|
||||
coolingTotal: -12344,
|
||||
coolingSensible: nil,
|
||||
registerCount: 1
|
||||
),
|
||||
Room.Create(
|
||||
// projectID: UUID(0),
|
||||
name: "Test",
|
||||
heatingLoad: 12345,
|
||||
coolingTotal: 12344,
|
||||
coolingSensible: -123,
|
||||
registerCount: 1
|
||||
),
|
||||
Room.Create(
|
||||
// projectID: UUID(0),
|
||||
name: "Test",
|
||||
heatingLoad: 12345,
|
||||
coolingTotal: 12344,
|
||||
coolingSensible: nil,
|
||||
registerCount: -1
|
||||
),
|
||||
Room.Create(
|
||||
// projectID: UUID(0),
|
||||
name: "",
|
||||
heatingLoad: -12345,
|
||||
coolingTotal: -12344,
|
||||
coolingSensible: -1,
|
||||
registerCount: -1
|
||||
),
|
||||
Room.Create(
|
||||
// projectID: UUID(0),
|
||||
name: "Test",
|
||||
heatingLoad: 12345,
|
||||
coolingTotal: nil,
|
||||
coolingSensible: nil,
|
||||
registerCount: 1
|
||||
),
|
||||
]
|
||||
)
|
||||
func validations(room: Room.Create) throws {
|
||||
#expect(throws: (any Error).self) {
|
||||
// do {
|
||||
try room.toModel(projectID: UUID(0)).validate()
|
||||
// } catch {
|
||||
// print("\(error)")
|
||||
// throw error
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
95
Tests/DatabaseClientTests/TrunkSizeTests.swift
Normal file
95
Tests/DatabaseClientTests/TrunkSizeTests.swift
Normal file
@@ -0,0 +1,95 @@
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
import Testing
|
||||
|
||||
@testable import DatabaseClient
|
||||
|
||||
@Suite
|
||||
struct TrunkSizeTests {
|
||||
|
||||
@Test
|
||||
func happyPath() async throws {
|
||||
try await withTestUserAndProject { _, project in
|
||||
@Dependency(\.database) var database
|
||||
|
||||
let room = try await database.rooms.create(
|
||||
project.id,
|
||||
.init(
|
||||
name: "Test", heatingLoad: 12345, coolingTotal: 12345,
|
||||
coolingSensible: nil, registerCount: 5
|
||||
)
|
||||
)
|
||||
|
||||
let trunk = try await database.trunkSizes.create(
|
||||
.init(
|
||||
projectID: project.id,
|
||||
type: .supply,
|
||||
rooms: [room.id: [1, 2, 3]],
|
||||
height: 8,
|
||||
name: "Test Trunk"
|
||||
)
|
||||
)
|
||||
|
||||
let fetched = try await database.trunkSizes.fetch(project.id)
|
||||
#expect(fetched == [trunk])
|
||||
|
||||
let got = try await database.trunkSizes.get(trunk.id)
|
||||
#expect(got == trunk)
|
||||
|
||||
let updated = try await database.trunkSizes.update(
|
||||
trunk.id, .init(type: .return)
|
||||
)
|
||||
#expect(updated.type == .return)
|
||||
#expect(updated.id == trunk.id)
|
||||
|
||||
try await database.trunkSizes.delete(trunk.id)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func notFound() async throws {
|
||||
try await withTestUserAndProject { _, project in
|
||||
@Dependency(\.database.trunkSizes) var trunks
|
||||
|
||||
await #expect(throws: NotFoundError.self) {
|
||||
try await trunks.create(
|
||||
.init(projectID: project.id, type: .supply, rooms: [UUID(0): [1]])
|
||||
)
|
||||
}
|
||||
|
||||
await #expect(throws: NotFoundError.self) {
|
||||
try await trunks.delete(UUID(0))
|
||||
}
|
||||
|
||||
await #expect(throws: NotFoundError.self) {
|
||||
try await trunks.update(UUID(0), .init(type: .return))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
TrunkModel(projectID: UUID(0), type: .return, height: 8, name: ""),
|
||||
TrunkModel(projectID: UUID(0), type: .return, height: -8, name: "Test"),
|
||||
]
|
||||
)
|
||||
func validations(model: TrunkModel) {
|
||||
#expect(throws: (any Error).self) {
|
||||
try model.validate()
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
TrunkRoomModel(trunkID: UUID(0), roomID: UUID(0), registers: [-1, 1], type: .return),
|
||||
TrunkRoomModel(trunkID: UUID(0), roomID: UUID(0), registers: [1, -1], type: .return),
|
||||
TrunkRoomModel(trunkID: UUID(0), roomID: UUID(0), registers: [], type: .return),
|
||||
]
|
||||
)
|
||||
func trunkRoomModelValidations(model: TrunkRoomModel) {
|
||||
#expect(throws: (any Error).self) {
|
||||
try model.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import DatabaseClient
|
||||
import Dependencies
|
||||
import Foundation
|
||||
import ManualDCore
|
||||
@@ -41,26 +40,6 @@ struct UserDatabaseTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func createUserFails() async throws {
|
||||
try await withDatabase {
|
||||
@Dependency(\.database.users) var users
|
||||
|
||||
await #expect(throws: ValidationError.self) {
|
||||
try await users.create(.init(email: "", password: "", confirmPassword: ""))
|
||||
}
|
||||
|
||||
await #expect(throws: ValidationError.self) {
|
||||
try await users.create(.init(email: "testy@example.com", password: "", confirmPassword: ""))
|
||||
}
|
||||
|
||||
await #expect(throws: ValidationError.self) {
|
||||
try await users.create(
|
||||
.init(email: "testy@example.com", password: "super-secret", confirmPassword: ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func deleteFailsWithInvalidUserID() async throws {
|
||||
try await withDatabase {
|
||||
@@ -148,4 +127,64 @@ struct UserDatabaseTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
UserProfileModel(
|
||||
userID: UUID(0), firstName: "", lastName: "McTestface", companyName: "Acme Co.",
|
||||
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: "55555"
|
||||
),
|
||||
UserProfileModel(
|
||||
userID: UUID(0), firstName: "Testy", lastName: "", companyName: "Acme Co.",
|
||||
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: "55555"
|
||||
),
|
||||
UserProfileModel(
|
||||
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "",
|
||||
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: "55555"
|
||||
),
|
||||
UserProfileModel(
|
||||
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
|
||||
streetAddress: "", city: "Nowhere", state: "CA", zipCode: "55555"
|
||||
),
|
||||
UserProfileModel(
|
||||
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
|
||||
streetAddress: "1234 Sesame St", city: "", state: "CA", zipCode: "55555"
|
||||
),
|
||||
UserProfileModel(
|
||||
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
|
||||
streetAddress: "1234 Sesame St", city: "Nowhere", state: "", zipCode: "55555"
|
||||
),
|
||||
UserProfileModel(
|
||||
userID: UUID(0), firstName: "Testy", lastName: "McTestface", companyName: "Acme Co.",
|
||||
streetAddress: "1234 Sesame St", city: "Nowhere", state: "CA", zipCode: ""
|
||||
),
|
||||
]
|
||||
)
|
||||
func profileValidations(model: UserProfileModel) {
|
||||
var errors = [String]()
|
||||
#expect(throws: (any Error).self) {
|
||||
do {
|
||||
try model.validate()
|
||||
} catch {
|
||||
// Just checking to make sure I'm not testing the same error over and over /
|
||||
// making sure I've reset to good values / only testing one property at a time.
|
||||
#expect(!errors.contains("\(error)"))
|
||||
errors.append("\(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test(
|
||||
arguments: [
|
||||
User.Create(email: "", password: "super-secret", confirmPassword: "super-secret"),
|
||||
User.Create(email: "testy@example.com", password: "", confirmPassword: "super-secret"),
|
||||
User.Create(email: "testy@example.com", password: "super-secret", confirmPassword: ""),
|
||||
User.Create(email: "testy@example.com", password: "super", confirmPassword: "super"),
|
||||
]
|
||||
)
|
||||
func userValidations(model: User.Create) {
|
||||
#expect(throws: (any Error).self) {
|
||||
try model.validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,42 @@ import ViewController
|
||||
@Suite(.snapshots(record: .failed))
|
||||
struct ViewControllerTests {
|
||||
|
||||
@Test
|
||||
func home() async throws {
|
||||
try await withDependencies {
|
||||
$0.viewController = .liveValue
|
||||
$0.auth = .failing
|
||||
} operation: {
|
||||
@Dependency(\.viewController) var viewController
|
||||
let home = try await viewController.view(.test(.home))
|
||||
assertSnapshot(of: home, as: .html)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func ductulator() async throws {
|
||||
try await withDependencies {
|
||||
$0.viewController = .liveValue
|
||||
$0.auth = .failing
|
||||
} operation: {
|
||||
@Dependency(\.viewController) var viewController
|
||||
let view = try await viewController.view(.test(.ductulator(.index)))
|
||||
assertSnapshot(of: view, as: .html)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func privacyPolicy() async throws {
|
||||
try await withDependencies {
|
||||
$0.viewController = .liveValue
|
||||
$0.auth = .failing
|
||||
} operation: {
|
||||
@Dependency(\.viewController) var viewController
|
||||
let view = try await viewController.view(.test(.privacyPolicy))
|
||||
assertSnapshot(of: view, as: .html)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
func login() async throws {
|
||||
try await withDependencies {
|
||||
@@ -103,7 +139,8 @@ struct ViewControllerTests {
|
||||
let mockDuctSizes = DuctSizes.mock(
|
||||
equipmentInfo: equipment,
|
||||
rooms: rooms,
|
||||
trunks: trunks
|
||||
trunks: trunks,
|
||||
shr: project.sensibleHeatRatio ?? 0.83
|
||||
)
|
||||
|
||||
try await withDefaultDependencies {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Duct Calc</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta content="ductcalc.com" name="og:site_name">
|
||||
<meta content="Duct Calc" name="og:title">
|
||||
<meta content="Duct sizing based on ACCA, Manual-D." name="description">
|
||||
<meta content="Duct sizing based on ACCA, Manual-D." name="og:description">
|
||||
<meta content="/images/mand_logo.png" name="og:image">
|
||||
<meta content="/images/mand_logo.png" name="twitter:image">
|
||||
<meta content="Duct Calc" name="twitter:image:alt">
|
||||
<meta content="summary_large_image" name="twitter:card">
|
||||
<meta content="1536" name="og:image:width">
|
||||
<meta content="1024" name="og:image:height">
|
||||
<meta content="duct, hvac, duct-design, duct design, manual-d, manual d, design" name="keywords">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<script src="/js/htmx-download.js"></script>
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link rel="stylesheet" href="/css/output.css">
|
||||
<link rel="stylesheet" href="/css/htmx.css">
|
||||
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/images/favicon-32x32.png" type="image/png">
|
||||
<link rel="icon" href="/images/favicon-16x16.png" type="image/png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between" data-theme="default">
|
||||
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
|
||||
<div>
|
||||
<nav class="navbar w-full bg-base-300 text-base-content shadow-sm mb-4">
|
||||
<div class="flex flex-1 space-x-4 items-center">
|
||||
<div class="tooltip tooltip-right" data-tip="Home">
|
||||
<a class="flex w-fit h-fit text-2xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/">
|
||||
<img src="/images/mand_logo_sm.webp">
|
||||
Duct Calc<span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<div class="flex items-end space-x-4">
|
||||
<div class="tooltip tooltip-left" data-tip="Duct size calculator"><a class="btn btn-ghost btn-primary text-lg" href="/duct-size" target="_blank">Ductulator</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="flex justify-center items-center px-10">
|
||||
<div class="bg-base-300 rounded-3xl shadow-3xl
|
||||
p-6 w-full">
|
||||
<div class="flex space-x-6 items-center text-4xl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-calculator-icon lucide-calculator"><rect width="16" height="20" x="4" y="2" rx="2"/><line x1="8" x2="16" y1="6" y2="6"/><line x1="16" x2="16" y1="14" y2="18"/><path d="M16 10h.01"/><path d="M12 10h.01"/><path d="M8 10h.01"/><path d="M12 14h.01"/><path d="M8 14h.01"/><path d="M12 18h.01"/><path d="M8 18h.01"/></svg>
|
||||
<h1 class="text-4xl font-bold me-10">Ductulator</h1>
|
||||
</div>
|
||||
<p class="text-primary font-bold italic">Calculate duct size for the given parameters</p>
|
||||
<form class="space-y-4 mt-6" hx-post="/duct-size" hx-target="#resultView" hx-swap="outerHTML">
|
||||
<label class="input w-full"><span class="label">CFM</span>
|
||||
<input name="cfm" type="number" placeholder="1000" required autofocus>
|
||||
Friction Rate</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="frictionRate" value="0.06" required type="number" min="0.01" step="0.01">
|
||||
Height</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="height" type="number" placeholder="Height (Optional)"></label>
|
||||
<button class="btn btn-secondary btn-block mt-6" type="submit">Submit</button>
|
||||
</form>
|
||||
<div id="resultView"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div class="bottom-0 left-0 bg-error">
|
||||
<footer class="footer footer-horizontal footer-center
|
||||
bg-base-300 text-base-content p-4">
|
||||
<aside class="grid-flow-row items-center">
|
||||
<div class="flex mx-auto">
|
||||
<a class="btn btn-ghost" href="mailto:support@ductcalc.pro"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
|
||||
</g>
|
||||
</svg><span>support@ductcalc.pro</span></a>
|
||||
</div>
|
||||
Openly licensed via CC-BY-NC-SA 4.0<a class="btn btn-ghost mx-auto" href="https://github.com/m-housh/swift-duct-calc/src/branch/main/LICENSE" target="_blank"></a>
|
||||
<p class="">Copyright © 2026 - All rights reserved by Michael Housh</p>
|
||||
</aside>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Duct Calc</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta content="ductcalc.com" name="og:site_name">
|
||||
<meta content="Duct Calc" name="og:title">
|
||||
<meta content="Duct sizing based on ACCA, Manual-D." name="description">
|
||||
<meta content="Duct sizing based on ACCA, Manual-D." name="og:description">
|
||||
<meta content="/images/mand_logo.png" name="og:image">
|
||||
<meta content="/images/mand_logo.png" name="twitter:image">
|
||||
<meta content="Duct Calc" name="twitter:image:alt">
|
||||
<meta content="summary_large_image" name="twitter:card">
|
||||
<meta content="1536" name="og:image:width">
|
||||
<meta content="1024" name="og:image:height">
|
||||
<meta content="duct, hvac, duct-design, duct design, manual-d, manual d, design" name="keywords">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<script src="/js/htmx-download.js"></script>
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link rel="stylesheet" href="/css/output.css">
|
||||
<link rel="stylesheet" href="/css/htmx.css">
|
||||
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/images/favicon-32x32.png" type="image/png">
|
||||
<link rel="icon" href="/images/favicon-16x16.png" type="image/png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between" data-theme="default">
|
||||
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
|
||||
<div>
|
||||
<div class="flex justify-end space-x-4 m-4">
|
||||
<div class="tooltip tooltip-left" data-tip="Duct size calculator"><a class="btn btn-ghost btn-accent text-lg" href="/duct-size" target="_blank">Ductulator</a></div>
|
||||
<button class="btn btn-ghost btn-secondary text-lg" hx-get="/login" hx-target="body" hx-swap="outerHTML" hx-push-url="true">Login</button>
|
||||
</div>
|
||||
<div class="mx-10 lg:mx-20">
|
||||
<div class="relative text-center bg-base-300
|
||||
rounded-3xl shadow-3xl overflow-hidden">
|
||||
<div class="bg-secondary text-xl font-bold
|
||||
absolute top-10 -left-15
|
||||
px-6 py-2 w-[250px] -rotate-45">BETA</div>
|
||||
<div>
|
||||
<div class="flex justify-center mt-30 md:mt-15 lg:mt-6">
|
||||
<div class="flex items-end border-b-6 border-accent
|
||||
text-8xl font-bold my-auto space-2">
|
||||
<h1 class="me-2">Duct Calc</h1>
|
||||
<div>
|
||||
<span class="bg-secondary rounded-md
|
||||
text-5xl rotate-180 p-2 -mx-2" style="writing-mode: vertical-rl">Pro</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Open source residential duct design program<a class="btn btn-ghost text-md text-primary font-bold italic" href="https://github.com/m-housh/swift-duct-calc" target="_blank"></a>
|
||||
<p class="text-3xl py-6">Manual-D™ speed sheet, but on the web!</p>
|
||||
<button class="btn btn-xl btn-primary mt-6" hx-get="/signup" hx-target="body" hx-swap="outerHTML">Get Started</button>
|
||||
<p class="text-xs italic my-6">
|
||||
Manual-D™ is a trademark of Air Conditioning Contractors of America (ACCA).
|
||||
|
||||
This site is not designed by or affiliated with ACCA.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 my-6">
|
||||
<div class="border-3 border-accent rounded-lg shadow-lg p-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<div class="text-5xl text-primary font-bold">Features</div>
|
||||
</div>
|
||||
<div class="text-xl ms-10 mt-10">
|
||||
<ul class="list-disc">
|
||||
<li>
|
||||
<div class="font-bold italic bg-secondary rounded-lg shadow-lg px-4 w-fit">Built by humans</div>
|
||||
</li>
|
||||
<li>Fully open source.</li>
|
||||
<li>Great replacement for speed sheet users.</li>
|
||||
<li>Great for classrooms.</li>
|
||||
<li>Store your projects in one place.</li>
|
||||
<li>Export final project to pdf.</li>
|
||||
<li>Import room loads via CSV file.</li>
|
||||
<li>Web based.</li>
|
||||
<li>Self host (run on your own infrastructure).</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-3 border-accent rounded-lg shadow-lg p-4">
|
||||
<div class="text-5xl text-primary font-bold">Coming Soon</div>
|
||||
<div class="text-xl ms-10 mt-10">
|
||||
<ul class="list-disc">
|
||||
<li>API integration.</li>
|
||||
<li>Command line interface.</li>
|
||||
<li>Fitting selection tool.</li>
|
||||
<li>Room load import from PDF.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<div class="bottom-0 left-0 bg-error">
|
||||
<footer class="footer footer-horizontal footer-center
|
||||
bg-base-300 text-base-content p-4">
|
||||
<aside class="grid-flow-row items-center">
|
||||
<div class="flex mx-auto">
|
||||
<a class="btn btn-ghost" href="mailto:support@ductcalc.pro"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
|
||||
</g>
|
||||
</svg><span>support@ductcalc.pro</span></a>
|
||||
</div>
|
||||
Openly licensed via CC-BY-NC-SA 4.0<a class="btn btn-ghost mx-auto" href="https://github.com/m-housh/swift-duct-calc/src/branch/main/LICENSE" target="_blank"></a>
|
||||
<p class="">Copyright © 2026 - All rights reserved by Michael Housh</p>
|
||||
</aside>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,6 +18,7 @@
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<script src="/js/htmx-download.js"></script>
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link rel="stylesheet" href="/css/output.css">
|
||||
<link rel="stylesheet" href="/css/htmx.css">
|
||||
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
|
||||
@@ -28,11 +29,14 @@
|
||||
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between">
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between" data-theme="default">
|
||||
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
|
||||
<dialog id="loginForm" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h1 class="text-2xl font-bold mb-6">Login</h1>
|
||||
<div class="flex justify-between">
|
||||
<h1 class="text-2xl font-bold mb-6">Login</h1>
|
||||
Privacy Policy<a class="btn btn-link" href="/privacy-policy" target="_blank"></a>
|
||||
</div>
|
||||
<form method="post" class="space-y-4">
|
||||
<div>
|
||||
<label class="input validator w-full"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Duct Calc</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta content="ductcalc.com" name="og:site_name">
|
||||
<meta content="Duct Calc" name="og:title">
|
||||
<meta content="Duct sizing based on ACCA, Manual-D." name="description">
|
||||
<meta content="Duct sizing based on ACCA, Manual-D." name="og:description">
|
||||
<meta content="/images/mand_logo.png" name="og:image">
|
||||
<meta content="/images/mand_logo.png" name="twitter:image">
|
||||
<meta content="Duct Calc" name="twitter:image:alt">
|
||||
<meta content="summary_large_image" name="twitter:card">
|
||||
<meta content="1536" name="og:image:width">
|
||||
<meta content="1024" name="og:image:height">
|
||||
<meta content="duct, hvac, duct-design, duct design, manual-d, manual d, design" name="keywords">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<script src="/js/htmx-download.js"></script>
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link rel="stylesheet" href="/css/output.css">
|
||||
<link rel="stylesheet" href="/css/htmx.css">
|
||||
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" href="/images/favicon-32x32.png" type="image/png">
|
||||
<link rel="icon" href="/images/favicon-16x16.png" type="image/png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/images/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/site.webmanifest">
|
||||
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between" data-theme="default">
|
||||
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
|
||||
<div class="p-10 space-y-4">
|
||||
<h1 class="text-3xl font-bold">Privacy Policy</h1>
|
||||
<p>Last updated: February 10, 2026</p>
|
||||
<p>
|
||||
This Privacy Policy describes Our policies and procedures on the collection, use and disclosure
|
||||
of Your information when You use the Service and tells You about Your privacy rights and how the
|
||||
law protects You.
|
||||
</p>
|
||||
<p>
|
||||
We use Your Personal Data to provide and improve the Service. By using the Service, You agree
|
||||
to the collection and use of information in accordance with this Privacy Policy. This Privacy
|
||||
Policy has been created with the help of the<a href="https://www.termsfeed.com/privacy-policy-generator/" target="_blank"> TermsFeed Privacy Policy Generator</a>
|
||||
</p>
|
||||
<h2 class="text-2xl font-bold">Interpretation and Definitions</h2>
|
||||
<h3 class="text-xl font-bold">Interpretation</h3>
|
||||
<p>
|
||||
The words whose initial letters are capitalized have meanings defined under the
|
||||
following conditions. The following definitions shall have the same meaning
|
||||
regardless of whether they appear in singular or in plural.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold">Definitions</h3>
|
||||
<p>For the purposes of this Privacy Policy:</p>
|
||||
<ul class="list-disc px-6 space-y-2">
|
||||
<li><span class="font-bold">Account</span> means a unique account created for You to access our Service or parts of our Service.</li>
|
||||
<li>
|
||||
<span class="font-bold">Affiliate</span> means an entity that controls, is controlled by, or is under common control with a
|
||||
party, where "control" means ownership of 50% or more of the shares, equity interest or other
|
||||
securities entitled to vote for election of directors or other managing authority.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">Company</span> (referred to as either "the Company", "We", "Us" or "Our" in this Privacy Policy)
|
||||
refers to Duct Calc.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">Cookies</span> are small files that are placed on Your computer, mobile device or any other device by
|
||||
a website, containing the details of Your browsing history on that website among its many uses.
|
||||
</li>
|
||||
<li><span class="font-bold">Country</span> refers to: Ohio, United States</li>
|
||||
<li>
|
||||
<span class="font-bold">Device</span> means any device that can access the Service such as a computer, a cell phone or a
|
||||
digital tablet.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">Personal Data</span> (or "Personal Information") is any information that relates to an identified or
|
||||
identifiable individual.
|
||||
|
||||
We use "Personal Data" and "Personal Information" interchangeably unless a law uses a specific
|
||||
term.
|
||||
</li>
|
||||
<li><span class="font-bold">Service</span> refers to the Website.</li>
|
||||
<li>
|
||||
<span class="font-bold">Service Provider</span> means any natural or legal person who processes the data on behalf of the
|
||||
Company. It refers to third-party companies or individuals employed by the Company to facilitate
|
||||
the Service, to provide the Service on behalf of the Company, to perform services related to the
|
||||
Service or to assist the Company in analyzing how the Service is used.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">Usage Data</span> refers to data collected automatically, either generated by the use of the Service
|
||||
or from the Service infrastructure itself (for example, the duration of a page visit).
|
||||
</li>
|
||||
<li><span class="font-bold">Website</span> refers to Duct Calc, accessible from [https://ductcalc.pro](https://ductcalc.pro).</li>
|
||||
<li>
|
||||
<span class="font-bold">You</span> means the individual accessing or using the Service, or the company, or other legal entity
|
||||
on behalf of which such individual is accessing or using the Service, as applicable.
|
||||
</li>
|
||||
</ul>
|
||||
<h2 class="text-2xl font-bold">Collecting and Using Your Personal Data</h2>
|
||||
<h3 class="text-xl font-bold">Types of Data Collected</h3>
|
||||
<h4 class="text-lg font-bold">Personal Data</h4>
|
||||
<p>
|
||||
While using Our Service, We may ask You to provide Us with certain personally identifiable
|
||||
information that can be used to contact or identify You. Personally identifiable information may
|
||||
include, but is not limited to:
|
||||
</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>Email address</li>
|
||||
<li>First and last name</li>
|
||||
<li>Phone number</li>
|
||||
<li>Address, State, Province, ZIP/Postal Code, City</li>
|
||||
</ul>
|
||||
<h4 class="text-lg font-bold">Usage Data</h4>
|
||||
<p>
|
||||
Usage Data may include information such as Your Device's Internet Protocol address (e.g. IP
|
||||
address), browser type, browser version, the pages of our Service that You visit, the time and date
|
||||
of Your visit, the time spent on those pages, unique device identifiers and other diagnostic data.
|
||||
|
||||
When You access the Service by or through a mobile device, We may collect certain information
|
||||
automatically, including, but not limited to, the type of mobile device You use, Your mobile
|
||||
device's unique ID, the IP address of Your mobile device, Your mobile operating system, the type of
|
||||
mobile Internet browser You use, unique device identifiers and other diagnostic data.
|
||||
|
||||
We may also collect information that Your browser sends whenever You visit Our Service or when You
|
||||
access the Service by or through a mobile device.
|
||||
</p>
|
||||
<h4 class="text-lg font-bold">Tracking Technologies and Cookies</h4>
|
||||
<p>
|
||||
We use Cookies and similar tracking technologies to track the activity on Our Service and store
|
||||
certain information. Tracking technologies We use include beacons, tags, and scripts to collect and
|
||||
track information and to improve and analyze Our Service. The technologies We use may include:
|
||||
</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>
|
||||
<span class="font-bold">Cookies or Browser Cookies.</span> A cookie is a small file placed on Your Device. You can instruct
|
||||
Your browser to refuse all Cookies or to indicate when a Cookie is being sent. However, if You do
|
||||
not accept Cookies, You may not be able to use some parts of our Service.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">Web Beacons</span> Certain sections of our Service and our emails may contain small electronic files
|
||||
known as web beacons (also referred to as clear gifs, pixel tags, and single-pixel gifs) that
|
||||
permit the Company, for example, to count users who have visited those pages or opened an email
|
||||
and for other related website statistics (for example, recording the popularity of a certain
|
||||
section and verifying system and server integrity).
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
|
||||
Cookies can be "Persistent" or "Session" Cookies. Persistent Cookies remain on Your personal
|
||||
computer or mobile device when You go offline, while Session Cookies are deleted as soon as You
|
||||
close Your web browser.
|
||||
|
||||
Where required by law, we use non-essential cookies (such as analytics, advertising, and remarketing
|
||||
cookies) only with Your consent. You can withdraw or change Your consent at any time using Our
|
||||
cookie preferences tool (if available) or through Your browser/device settings. Withdrawing consent
|
||||
does not affect the lawfulness of processing based on consent before its withdrawal.
|
||||
|
||||
We use both Session and Persistent Cookies for the purposes set out below:
|
||||
</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>
|
||||
<p class="font-bold">Necessary / Essential Cookies</p>
|
||||
<p class="mx-6">
|
||||
Type: Session Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies are essential to provide You with services available through the Website
|
||||
and to enable You to use some of its features. They help to authenticate users and prevent
|
||||
fraudulent use of user accounts. Without these Cookies, the services that You have asked for
|
||||
cannot be provided, and We only use these Cookies to provide You with those services.
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<p class="font-bold">Functionality Cookies</p>
|
||||
<p class="mx-6">
|
||||
Type: Persistent Cookies
|
||||
|
||||
Administered by: Us
|
||||
|
||||
Purpose: These Cookies allow Us to remember choices You make when You use the Website, such as
|
||||
remembering your login details or language preference. The purpose of these Cookies is to
|
||||
provide You with a more personal experience and to avoid You having to re-enter your preferences
|
||||
every time You use the Website.
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
For more information about the cookies we use and your choices regarding cookies, please visit our
|
||||
Cookies Policy or the Cookies section of Our Privacy Policy.
|
||||
</p>
|
||||
<h3 class="text-2xl font-bold">Use of Your Personal Data</h3>
|
||||
<p>The Company may use Personal Data for the following purposes:</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li><span class="font-bold">To provide and maintain our Service</span>, including to monitor the usage of our Service.</li>
|
||||
<li>
|
||||
<span class="font-bold">To manage Your Account:</span> to manage Your registration as a user of the Service. The Personal
|
||||
Data You provide can give You access to different functionalities of the Service that are
|
||||
available to You as a registered user.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">For the performance of a contract:</span> the development, compliance and undertaking of the purchase
|
||||
contract for the products, items or services You have purchased or of any other contract with Us
|
||||
through the Service.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">To contact You:</span> To contact You by email, telephone calls, SMS, or other equivalent forms of
|
||||
electronic communication, such as a mobile application's push notifications regarding updates or
|
||||
informative communications related to the functionalities, products or contracted services,
|
||||
including the security updates, when necessary or reasonable for their implementation.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">To provide You</span> with news, special offers, and general information about other goods, services
|
||||
and events which We offer that are similar to those that you have already purchased or inquired
|
||||
about unless You have opted not to receive such information.
|
||||
</li>
|
||||
<li><span class="font-bold">To manage Your requests:</span> To attend and manage Your requests to Us.</li>
|
||||
<li>
|
||||
<span class="font-bold">For business transfers:</span> We may use Your Personal Data to evaluate or conduct a merger,
|
||||
divestiture, restructuring, reorganization, dissolution, or other sale or transfer of some or all
|
||||
of Our assets, whether as a going concern or as part of bankruptcy, liquidation, or similar
|
||||
proceeding, in which Personal Data held by Us about our Service users is among the assets
|
||||
transferred.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">For other purposes:</span> We may use Your information for other purposes, such as data analysis,
|
||||
identifying usage trends, determining the effectiveness of our promotional campaigns and to
|
||||
evaluate and improve our Service, products, services, marketing and your experience.
|
||||
</li>
|
||||
</ul>
|
||||
<p>We may share Your Personal Data in the following situations:</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>
|
||||
<span class="font-bold">With Service Providers:</span> We may share Your Personal Data with Service Providers to monitor and
|
||||
analyze the use of our Service, to contact You.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">For business transfers:</span> We may share or transfer Your Personal Data in connection with, or
|
||||
during negotiations of, any merger, sale of Company assets, financing, or acquisition of all or a
|
||||
portion of Our business to another company.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">With Affiliates:</span> We may share Your Personal Data with Our affiliates, in which case we will
|
||||
require those affiliates to honor this Privacy Policy. Affiliates include Our parent company and
|
||||
any other subsidiaries, joint venture partners or other companies that We control or that are
|
||||
under common control with Us.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">With business partners:</span> We may share Your Personal Data with Our business partners to offer
|
||||
You certain products, services or promotions.
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-bold">With other users:</span> If Our Service offers public areas, when You share Personal Data or
|
||||
otherwise interact in the public areas with other users, such information may be viewed by all
|
||||
users and may be publicly distributed outside.
|
||||
</li>
|
||||
<li><span class="font-bold">With Your consent:</span> We may disclose Your Personal Data for any other purpose with Your consent.</li>
|
||||
</ul>
|
||||
<h3 class="text-2xl font-bold">Retention of Your Personal Data</h3>
|
||||
<p>
|
||||
The Company will retain Your Personal Data only for as long as is necessary for the purposes set out
|
||||
in this Privacy Policy. We will retain and use Your Personal Data to the extent necessary to comply
|
||||
with our legal obligations (for example, if We are required to retain Your data to comply with
|
||||
applicable laws), resolve disputes, and enforce our legal agreements and policies.
|
||||
</p>
|
||||
<p>
|
||||
Where possible, We apply shorter retention periods and/or reduce identifiability by deleting,
|
||||
aggregating, or anonymizing data. Unless otherwise stated, the retention periods below are maximum
|
||||
periods ("up to") and We may delete or anonymize data sooner when it is no longer needed for the
|
||||
relevant purpose. We apply different retention periods to different categories of Personal Data
|
||||
based on the purpose of processing and legal obligations:
|
||||
</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>
|
||||
Account Information
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>
|
||||
User Accounts: retained for the duration of your account relationship plus up to 24 months
|
||||
after account closure to handle any post-termination issues or resolve disputes.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Customer Support Data
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>
|
||||
Support tickets and correspondence: up to 24 months from the date of ticket closure to resolve
|
||||
follow-up inquiries, track service quality, and defend against potential legal claims.
|
||||
</li>
|
||||
<li>Chat transcripts: up to 24 months for quality assurance and staff training purposes.</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
Usage Data
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>
|
||||
Website analytics data (cookies, IP addresses, device identifiers): up to 24 months from the
|
||||
date of collection, which allows us to analyze trends while respecting privacy principles.
|
||||
</li>
|
||||
<li>
|
||||
Server logs (IP addresses, access times): up to 24 months for security monitoring and
|
||||
troubleshooting purposes.
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Usage Data is retained in accordance with the retention periods described above, and may be retained
|
||||
longer only where necessary for security, fraud prevention, or legal compliance.
|
||||
</p>
|
||||
<p>We may retain Personal Data beyond the periods stated above for different reasons:</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>
|
||||
Legal obligation: We are required by law to retain specific data (e.g., financial records for tax
|
||||
authorities).
|
||||
</li>
|
||||
<li>Legal claims: Data is necessary to establish, exercise, or defend legal claims.</li>
|
||||
<li>Your explicit request: You ask Us to retain specific information.</li>
|
||||
<li>Technical limitations: Data exists in backup systems that are scheduled for routine deletion.</li>
|
||||
</ul>
|
||||
<p>You may request information about how long We will retain Your Personal Data by contacting Us.</p>
|
||||
<p>
|
||||
When retention periods expire, We securely delete or anonymize Personal Data according to the
|
||||
following procedures:
|
||||
</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>Deletion: Personal Data is removed from Our systems and no longer actively processed.</li>
|
||||
<li>
|
||||
Backup retention: Residual copies may remain in encrypted backups for a limited period consistent
|
||||
with our backup retention schedule and are not restored except where necessary for security,
|
||||
disaster recovery, or legal compliance.
|
||||
</li>
|
||||
<li>
|
||||
Anonymization: In some cases, We convert Personal Data into anonymous statistical data that cannot
|
||||
be linked back to You. This anonymized data may be retained indefinitely for research and
|
||||
analytics.
|
||||
</li>
|
||||
</ul>
|
||||
<h3 class="text-xl font-bold">Transfer of Your Personal Data</h3>
|
||||
<p>
|
||||
Your information, including Personal Data, is processed at the Company's operating offices and in
|
||||
any other places where the parties involved in the processing are located. It means that this
|
||||
information may be transferred to — and maintained on — computers located outside of Your state,
|
||||
province, country or other governmental jurisdiction where the data protection laws may differ from
|
||||
those from Your jurisdiction.
|
||||
</p>
|
||||
<p>
|
||||
Where required by applicable law, We will ensure that international transfers of Your Personal Data
|
||||
are subject to appropriate safeguards and supplementary measures where appropriate. The Company will
|
||||
take all steps reasonably necessary to ensure that Your data is treated securely and in accordance
|
||||
with this Privacy Policy and no transfer of Your Personal Data will take place to an organization or
|
||||
a country unless there are adequate controls in place including the security of Your data and other
|
||||
personal information.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold">Delete Your Personal Data</h3>
|
||||
<p>
|
||||
You have the right to delete or request that We assist in deleting the Personal Data that We have
|
||||
collected about You.
|
||||
</p>
|
||||
<p>
|
||||
Our Service may give You the ability to delete certain information about You from within the
|
||||
Service.
|
||||
</p>
|
||||
<p>
|
||||
You may update, amend, or delete Your information at any time by signing in to Your Account, if you
|
||||
have one, and visiting the account settings section that allows you to manage Your personal
|
||||
information. You may also contact Us to request access to, correct, or delete any Personal Data that
|
||||
You have provided to Us.
|
||||
</p>
|
||||
<p>
|
||||
Please note, however, that We may need to retain certain information when we have a legal obligation
|
||||
or lawful basis to do so.
|
||||
</p>
|
||||
<h3 class="text-xl font-bold">Disclosure of Your Personal Data</h3>
|
||||
<h4 class="text-lg font-bold">Business Transactions</h4>
|
||||
<p>
|
||||
If the Company is involved in a merger, acquisition or asset sale, Your Personal Data may be
|
||||
transferred. We will provide notice before Your Personal Data is transferred and becomes subject to
|
||||
a different Privacy Policy.
|
||||
</p>
|
||||
<h4 class="text-lg font-bold">Law enforcement</h4>
|
||||
<p>
|
||||
Under certain circumstances, the Company may be required to disclose Your Personal Data if required
|
||||
to do so by law or in response to valid requests by public authorities (e.g. a court or a government
|
||||
agency).
|
||||
</p>
|
||||
<h4 class="text-lg font-bold">Other legal requirements</h4>
|
||||
<p>
|
||||
The Company may disclose Your Personal Data in the good faith belief that such action is necessary
|
||||
to:
|
||||
</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li>Comply with a legal obligation</li>
|
||||
<li>Protect and defend the rights or property of the Company</li>
|
||||
<li>Prevent or investigate possible wrongdoing in connection with the Service</li>
|
||||
<li>Protect the personal safety of Users of the Service or the public</li>
|
||||
<li>Protect against legal liability</li>
|
||||
</ul>
|
||||
<h3 class="text-xl font-bold">Security of Your Personal Data</h3>
|
||||
<p>
|
||||
The security of Your Personal Data is important to Us, but remember that no method of transmission
|
||||
over the Internet, or method of electronic storage is 100% secure. While We strive to use
|
||||
commercially reasonable means to protect Your Personal Data, We cannot guarantee its absolute
|
||||
security.
|
||||
</p>
|
||||
<h2 class="text-2xl font-bold">Children's Privacy</h2>
|
||||
<p>
|
||||
Our Service does not address anyone under the age of 16. We do not knowingly collect personally
|
||||
identifiable information from anyone under the age of 16. If You are a parent or guardian and You
|
||||
are aware that Your child has provided Us with Personal Data, please contact Us. If We become aware
|
||||
that We have collected Personal Data from anyone under the age of 16 without verification of
|
||||
parental consent, We take steps to remove that information from Our servers.
|
||||
</p>
|
||||
<p>
|
||||
If We need to rely on consent as a legal basis for processing Your information and Your country
|
||||
requires consent from a parent, We may require Your parent's consent before We collect and use that
|
||||
information.
|
||||
</p>
|
||||
<h2 class="text-2xl font-bold">Links to Other Websites</h2>
|
||||
<p>
|
||||
Our Service may contain links to other websites that are not operated by Us. If You click on a third
|
||||
party link, You will be directed to that third party's site. We strongly advise You to review the
|
||||
Privacy Policy of every site You visit.
|
||||
</p>
|
||||
<p>
|
||||
We have no control over and assume no responsibility for the content, privacy policies or practices
|
||||
of any third party sites or services.
|
||||
</p>
|
||||
<h2 class="text-2xl font-bold">Changes to this Privacy Policy</h2>
|
||||
<p>
|
||||
We may update Our Privacy Policy from time to time. We will notify You of any changes by posting the
|
||||
new Privacy Policy on this page.
|
||||
</p>
|
||||
<p>
|
||||
We will let You know via email and/or a prominent notice on Our Service, prior to the change
|
||||
becoming effective and update the "Last updated" date at the top of this Privacy Policy.
|
||||
</p>
|
||||
<p>
|
||||
You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy
|
||||
Policy are effective when they are posted on this page.
|
||||
</p>
|
||||
<h2 class="text-2xl font-bold">Contact Us</h2>
|
||||
<p>If you have any questions about this Privacy Policy, You can contact us:</p>
|
||||
<ul class="list-disc mx-6 space-y-2">
|
||||
<li><span>By email: </span><a href="mailto://support@ductcalc.pro" class="btn btn-link">support@ductcalc.pro</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
<div class="bottom-0 left-0 bg-error">
|
||||
<footer class="footer footer-horizontal footer-center
|
||||
bg-base-300 text-base-content p-4">
|
||||
<aside class="grid-flow-row items-center">
|
||||
<div class="flex mx-auto">
|
||||
<a class="btn btn-ghost" href="mailto:support@ductcalc.pro"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
|
||||
</g>
|
||||
</svg><span>support@ductcalc.pro</span></a>
|
||||
</div>
|
||||
Openly licensed via CC-BY-NC-SA 4.0<a class="btn btn-ghost mx-auto" href="https://github.com/m-housh/swift-duct-calc/src/branch/main/LICENSE" target="_blank"></a>
|
||||
<p class="">Copyright © 2026 - All rights reserved by Michael Housh</p>
|
||||
</aside>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -18,6 +18,7 @@
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<script src="/js/htmx-download.js"></script>
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link rel="stylesheet" href="/css/output.css">
|
||||
<link rel="stylesheet" href="/css/htmx.css">
|
||||
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
|
||||
@@ -28,22 +29,31 @@
|
||||
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between">
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between" data-theme="default">
|
||||
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
|
||||
<div class="drawer lg:drawer-open h-full">
|
||||
<input id="my-drawer-1" type="checkbox" class="drawer-toggle">
|
||||
<div class="drawer-content overflow-auto">
|
||||
<nav class="navbar w-full bg-base-300 text-base-content shadow-sm mb-4">
|
||||
<div class="flex flex-1 space-x-4 items-center">
|
||||
<div class="tooltip tooltip-right" data-tip="Open sidebar"><label for="my-drawer-1" class="size-7 btn btn-square btn-ghost hover:bg-neutral hover:text-white" aria-label="open sidebar"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg></label></div>
|
||||
<div class="tooltip tooltip-right" data-tip="Home">
|
||||
<a class="flex w-fit h-fit text-xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/projects">
|
||||
<div class="tooltip tooltip-right" data-tip="Open / close sidebar"><label for="my-drawer-1" class="size-7 btn btn-square btn-ghost hover:bg-neutral hover:text-white" aria-label="open / close sidebar"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg></label></div>
|
||||
<div class="tooltip tooltip-right" data-tip="Projects">
|
||||
<a class="flex w-fit h-fit text-2xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/projects">
|
||||
<img src="/images/mand_logo_sm.webp">
|
||||
Duct Calc<span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<div class="tooltip tooltip-left" data-tip="Profile"><a href="/profile" class="btn btn-square btn-ghost hover:bg-neutral hover:text-white"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg></a></div>
|
||||
<div class="flex items-end space-x-4">
|
||||
<div class="tooltip tooltip-left" data-tip="Duct size calculator"><a class="btn btn-ghost btn-primary text-lg" href="/duct-size" target="_blank">Ductulator</a></div>
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<div class="btn m-1 btn btn-square btn-ghost hover:bg-neutral hover:text-white" tabindex="0" role="button"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg></div>
|
||||
<ul tabindex="-1" class="dropdown-content menu bg-base-200 rounded-box z-1 w-52 py-2 shadow-sm">
|
||||
<li><a href="/profile">Profile</a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="p-4">
|
||||
@@ -134,10 +144,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Project</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -153,10 +162,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Rooms</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -172,10 +180,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Equipment</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -191,10 +198,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>T.E.L.</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -210,10 +216,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Friction Rate</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -241,10 +246,25 @@ is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2
|
||||
</div>
|
||||
</main>
|
||||
<div class="bottom-0 left-0 bg-error">
|
||||
<footer class="footer sm:footer-horizontal footer-center
|
||||
<footer class="footer footer-horizontal footer-center
|
||||
bg-base-300 text-base-content p-4">
|
||||
<aside>
|
||||
<p>Copyright © 2026 - All rights reserved by Michael Housh</p>
|
||||
<aside class="grid-flow-row items-center">
|
||||
<div class="flex mx-auto">
|
||||
<a class="btn btn-ghost" href="mailto:support@ductcalc.pro"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
|
||||
</g>
|
||||
</svg><span>support@ductcalc.pro</span></a>
|
||||
</div>
|
||||
Openly licensed via CC-BY-NC-SA 4.0<a class="btn btn-ghost mx-auto" href="https://github.com/m-housh/swift-duct-calc/src/branch/main/LICENSE" target="_blank"></a>
|
||||
<p class="">Copyright © 2026 - All rights reserved by Michael Housh</p>
|
||||
</aside>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<script src="/js/htmx-download.js"></script>
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link rel="stylesheet" href="/css/output.css">
|
||||
<link rel="stylesheet" href="/css/htmx.css">
|
||||
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
|
||||
@@ -28,22 +29,31 @@
|
||||
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between">
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between" data-theme="default">
|
||||
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
|
||||
<div class="drawer lg:drawer-open h-full">
|
||||
<input id="my-drawer-1" type="checkbox" class="drawer-toggle">
|
||||
<div class="drawer-content overflow-auto">
|
||||
<nav class="navbar w-full bg-base-300 text-base-content shadow-sm mb-4">
|
||||
<div class="flex flex-1 space-x-4 items-center">
|
||||
<div class="tooltip tooltip-right" data-tip="Open sidebar"><label for="my-drawer-1" class="size-7 btn btn-square btn-ghost hover:bg-neutral hover:text-white" aria-label="open sidebar"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg></label></div>
|
||||
<div class="tooltip tooltip-right" data-tip="Home">
|
||||
<a class="flex w-fit h-fit text-xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/projects">
|
||||
<div class="tooltip tooltip-right" data-tip="Open / close sidebar"><label for="my-drawer-1" class="size-7 btn btn-square btn-ghost hover:bg-neutral hover:text-white" aria-label="open / close sidebar"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg></label></div>
|
||||
<div class="tooltip tooltip-right" data-tip="Projects">
|
||||
<a class="flex w-fit h-fit text-2xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/projects">
|
||||
<img src="/images/mand_logo_sm.webp">
|
||||
Duct Calc<span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<div class="tooltip tooltip-left" data-tip="Profile"><a href="/profile" class="btn btn-square btn-ghost hover:bg-neutral hover:text-white"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg></a></div>
|
||||
<div class="flex items-end space-x-4">
|
||||
<div class="tooltip tooltip-left" data-tip="Duct size calculator"><a class="btn btn-ghost btn-primary text-lg" href="/duct-size" target="_blank">Ductulator</a></div>
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<div class="btn m-1 btn btn-square btn-ghost hover:bg-neutral hover:text-white" tabindex="0" role="button"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg></div>
|
||||
<ul tabindex="-1" class="dropdown-content menu bg-base-200 rounded-box z-1 w-52 py-2 shadow-sm">
|
||||
<li><a href="/profile">Profile</a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="p-4">
|
||||
@@ -54,7 +64,7 @@ p-6 w-full">
|
||||
<div class="col-span-2">
|
||||
<h1 class="text-3xl font-bold">Room Loads</h1>
|
||||
</div>
|
||||
<div class="flex justify-end grow">
|
||||
<div class="flex justify-end grow space-x-4">
|
||||
<div class="tooltip tooltip-left" data-tip="Set sensible heat ratio">
|
||||
<button class="btn btn-primary text-lg font-bold py-2 " onclick="shrForm.showModal()">
|
||||
<div class="flex grow justify-end items-end space-x-4">
|
||||
@@ -107,7 +117,13 @@ p-6 w-full">
|
||||
<div class="flex justify-center">Register Count</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="flex justify-end me-2">
|
||||
<div class="flex justify-center">Delegated To</div>
|
||||
</th>
|
||||
<th>
|
||||
<div class="flex justify-end me-2 space-x-4">
|
||||
<div class="tooltip tooltip-left" data-tip="Upload CSV">
|
||||
<button class="btn btn-secondary" onclick="uploadCSV.showModal()"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-plus-corner-icon lucide-file-plus-corner"><path d="M11.35 22H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.706.706l3.588 3.588A2.4 2.4 0 0 1 20 8v5.35"/><path d="M14 2v5a1 1 0 0 0 1 1h5"/><path d="M14 19h6"/><path d="M17 16v6"/></svg></button>
|
||||
</div>
|
||||
<div class="tooltip tooltip-left" data-tip="Add Room">
|
||||
<button type="button" class="btn btn-primary mx-auto tooltip-left" onclick="roomForm.showModal()"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg></button>
|
||||
</div>
|
||||
@@ -130,6 +146,7 @@ p-6 w-full">
|
||||
<td>
|
||||
<div class="flex justify-center"><span>1</span></div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="flex justify-end">
|
||||
<div class="join">
|
||||
@@ -151,18 +168,34 @@ p-6 w-full">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000001.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
<h1 class="text-3xl font-bold pb-6">Room</h1>
|
||||
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000001" hx-target="body" hx-swap="outerHTML">
|
||||
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
|
||||
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000001">
|
||||
Name<label class="input w-full"><span class="label"></span>
|
||||
<input name="name" type="text" placeholder="Name" required autofocus value="Bed-1">
|
||||
Heating Load</label><label class="input w-full"><span class="label"></span>
|
||||
Level</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="level" type="number" placeholder="1 (Optional)" value="" min="-1" step="1"></label>
|
||||
<div class="text-sm italic -mt-2"><span class="text-primary">Use -1 or 0 for a basement</span></div>
|
||||
Heating Load<label class="input w-full"><span class="label"></span>
|
||||
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="3913.0">
|
||||
Cooling Total</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="2472.0">
|
||||
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="2472.0">
|
||||
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
|
||||
Registers</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="1"></label>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value=""></label>
|
||||
<div class="text-primary text-sm italic -mt-2">
|
||||
<p>Should enter at least one of the cooling loads.</p>
|
||||
<p>Both are also acceptable.</p>
|
||||
</div>
|
||||
Registers<label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="1" id="registerCount">
|
||||
Room</label><label class="select w-full"><span class="label"></span>
|
||||
<select name="delegatedTo">
|
||||
<option selected disabled>Delegate Airflow</option>
|
||||
<option value="00000000-0000-0000-0000-000000000001">Bed-1</option>
|
||||
<option value="00000000-0000-0000-0000-000000000002">Entry</option>
|
||||
<option value="00000000-0000-0000-0000-000000000003">Family Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000004">Kitchen</option>
|
||||
<option value="00000000-0000-0000-0000-000000000005">Living Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000006">Master</option>
|
||||
</select></label>
|
||||
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -183,6 +216,7 @@ p-6 w-full">
|
||||
<td>
|
||||
<div class="flex justify-center"><span>2</span></div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="flex justify-end">
|
||||
<div class="join">
|
||||
@@ -204,18 +238,34 @@ p-6 w-full">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000002.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
<h1 class="text-3xl font-bold pb-6">Room</h1>
|
||||
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000002" hx-target="body" hx-swap="outerHTML">
|
||||
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
|
||||
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000002">
|
||||
Name<label class="input w-full"><span class="label"></span>
|
||||
<input name="name" type="text" placeholder="Name" required autofocus value="Entry">
|
||||
Heating Load</label><label class="input w-full"><span class="label"></span>
|
||||
Level</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="level" type="number" placeholder="1 (Optional)" value="" min="-1" step="1"></label>
|
||||
<div class="text-sm italic -mt-2"><span class="text-primary">Use -1 or 0 for a basement</span></div>
|
||||
Heating Load<label class="input w-full"><span class="label"></span>
|
||||
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="8284.0">
|
||||
Cooling Total</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="2916.0">
|
||||
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="2916.0">
|
||||
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
|
||||
Registers</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="2"></label>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value=""></label>
|
||||
<div class="text-primary text-sm italic -mt-2">
|
||||
<p>Should enter at least one of the cooling loads.</p>
|
||||
<p>Both are also acceptable.</p>
|
||||
</div>
|
||||
Registers<label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="2" id="registerCount">
|
||||
Room</label><label class="select w-full"><span class="label"></span>
|
||||
<select name="delegatedTo">
|
||||
<option selected disabled>Delegate Airflow</option>
|
||||
<option value="00000000-0000-0000-0000-000000000001">Bed-1</option>
|
||||
<option value="00000000-0000-0000-0000-000000000002">Entry</option>
|
||||
<option value="00000000-0000-0000-0000-000000000003">Family Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000004">Kitchen</option>
|
||||
<option value="00000000-0000-0000-0000-000000000005">Living Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000006">Master</option>
|
||||
</select></label>
|
||||
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -236,6 +286,7 @@ p-6 w-full">
|
||||
<td>
|
||||
<div class="flex justify-center"><span>3</span></div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="flex justify-end">
|
||||
<div class="join">
|
||||
@@ -257,18 +308,34 @@ p-6 w-full">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000003.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
<h1 class="text-3xl font-bold pb-6">Room</h1>
|
||||
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000003" hx-target="body" hx-swap="outerHTML">
|
||||
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
|
||||
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000003">
|
||||
Name<label class="input w-full"><span class="label"></span>
|
||||
<input name="name" type="text" placeholder="Name" required autofocus value="Family Room">
|
||||
Heating Load</label><label class="input w-full"><span class="label"></span>
|
||||
Level</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="level" type="number" placeholder="1 (Optional)" value="" min="-1" step="1"></label>
|
||||
<div class="text-sm italic -mt-2"><span class="text-primary">Use -1 or 0 for a basement</span></div>
|
||||
Heating Load<label class="input w-full"><span class="label"></span>
|
||||
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="9785.0">
|
||||
Cooling Total</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="7446.0">
|
||||
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="7446.0">
|
||||
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
|
||||
Registers</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="3"></label>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value=""></label>
|
||||
<div class="text-primary text-sm italic -mt-2">
|
||||
<p>Should enter at least one of the cooling loads.</p>
|
||||
<p>Both are also acceptable.</p>
|
||||
</div>
|
||||
Registers<label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="3" id="registerCount">
|
||||
Room</label><label class="select w-full"><span class="label"></span>
|
||||
<select name="delegatedTo">
|
||||
<option selected disabled>Delegate Airflow</option>
|
||||
<option value="00000000-0000-0000-0000-000000000001">Bed-1</option>
|
||||
<option value="00000000-0000-0000-0000-000000000002">Entry</option>
|
||||
<option value="00000000-0000-0000-0000-000000000003">Family Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000004">Kitchen</option>
|
||||
<option value="00000000-0000-0000-0000-000000000005">Living Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000006">Master</option>
|
||||
</select></label>
|
||||
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -289,6 +356,7 @@ p-6 w-full">
|
||||
<td>
|
||||
<div class="flex justify-center"><span>2</span></div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="flex justify-end">
|
||||
<div class="join">
|
||||
@@ -310,18 +378,34 @@ p-6 w-full">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000004.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
<h1 class="text-3xl font-bold pb-6">Room</h1>
|
||||
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000004" hx-target="body" hx-swap="outerHTML">
|
||||
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
|
||||
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000004">
|
||||
Name<label class="input w-full"><span class="label"></span>
|
||||
<input name="name" type="text" placeholder="Name" required autofocus value="Kitchen">
|
||||
Heating Load</label><label class="input w-full"><span class="label"></span>
|
||||
Level</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="level" type="number" placeholder="1 (Optional)" value="" min="-1" step="1"></label>
|
||||
<div class="text-sm italic -mt-2"><span class="text-primary">Use -1 or 0 for a basement</span></div>
|
||||
Heating Load<label class="input w-full"><span class="label"></span>
|
||||
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="4518.0">
|
||||
Cooling Total</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="5096.0">
|
||||
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="5096.0">
|
||||
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
|
||||
Registers</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="2"></label>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value=""></label>
|
||||
<div class="text-primary text-sm italic -mt-2">
|
||||
<p>Should enter at least one of the cooling loads.</p>
|
||||
<p>Both are also acceptable.</p>
|
||||
</div>
|
||||
Registers<label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="2" id="registerCount">
|
||||
Room</label><label class="select w-full"><span class="label"></span>
|
||||
<select name="delegatedTo">
|
||||
<option selected disabled>Delegate Airflow</option>
|
||||
<option value="00000000-0000-0000-0000-000000000001">Bed-1</option>
|
||||
<option value="00000000-0000-0000-0000-000000000002">Entry</option>
|
||||
<option value="00000000-0000-0000-0000-000000000003">Family Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000004">Kitchen</option>
|
||||
<option value="00000000-0000-0000-0000-000000000005">Living Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000006">Master</option>
|
||||
</select></label>
|
||||
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -342,6 +426,7 @@ p-6 w-full">
|
||||
<td>
|
||||
<div class="flex justify-center"><span>2</span></div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="flex justify-end">
|
||||
<div class="join">
|
||||
@@ -363,18 +448,34 @@ p-6 w-full">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000005.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
<h1 class="text-3xl font-bold pb-6">Room</h1>
|
||||
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000005" hx-target="body" hx-swap="outerHTML">
|
||||
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
|
||||
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000005">
|
||||
Name<label class="input w-full"><span class="label"></span>
|
||||
<input name="name" type="text" placeholder="Name" required autofocus value="Living Room">
|
||||
Heating Load</label><label class="input w-full"><span class="label"></span>
|
||||
Level</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="level" type="number" placeholder="1 (Optional)" value="" min="-1" step="1"></label>
|
||||
<div class="text-sm italic -mt-2"><span class="text-primary">Use -1 or 0 for a basement</span></div>
|
||||
Heating Load<label class="input w-full"><span class="label"></span>
|
||||
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="7553.0">
|
||||
Cooling Total</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="6829.0">
|
||||
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="6829.0">
|
||||
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
|
||||
Registers</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="2"></label>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value=""></label>
|
||||
<div class="text-primary text-sm italic -mt-2">
|
||||
<p>Should enter at least one of the cooling loads.</p>
|
||||
<p>Both are also acceptable.</p>
|
||||
</div>
|
||||
Registers<label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="2" id="registerCount">
|
||||
Room</label><label class="select w-full"><span class="label"></span>
|
||||
<select name="delegatedTo">
|
||||
<option selected disabled>Delegate Airflow</option>
|
||||
<option value="00000000-0000-0000-0000-000000000001">Bed-1</option>
|
||||
<option value="00000000-0000-0000-0000-000000000002">Entry</option>
|
||||
<option value="00000000-0000-0000-0000-000000000003">Family Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000004">Kitchen</option>
|
||||
<option value="00000000-0000-0000-0000-000000000005">Living Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000006">Master</option>
|
||||
</select></label>
|
||||
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -395,6 +496,7 @@ p-6 w-full">
|
||||
<td>
|
||||
<div class="flex justify-center"><span>2</span></div>
|
||||
</td>
|
||||
<td></td>
|
||||
<td>
|
||||
<div class="flex justify-end">
|
||||
<div class="join">
|
||||
@@ -416,18 +518,34 @@ p-6 w-full">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm_00000000000000000000000000000006.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
<h1 class="text-3xl font-bold pb-6">Room</h1>
|
||||
<form class="grid grid-cols-1 gap-4" hx-patch="/projects/00000000-0000-0000-0000-000000000000/rooms/00000000-0000-0000-0000-000000000006" hx-target="body" hx-swap="outerHTML">
|
||||
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
|
||||
<input class="hidden" name="id" value="00000000-0000-0000-0000-000000000006">
|
||||
Name<label class="input w-full"><span class="label"></span>
|
||||
<input name="name" type="text" placeholder="Name" required autofocus value="Master">
|
||||
Heating Load</label><label class="input w-full"><span class="label"></span>
|
||||
Level</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="level" type="number" placeholder="1 (Optional)" value="" min="-1" step="1"></label>
|
||||
<div class="text-sm italic -mt-2"><span class="text-primary">Use -1 or 0 for a basement</span></div>
|
||||
Heating Load<label class="input w-full"><span class="label"></span>
|
||||
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="8202.0">
|
||||
Cooling Total</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="2076.0">
|
||||
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="2076.0">
|
||||
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
|
||||
Registers</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="2"></label>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value=""></label>
|
||||
<div class="text-primary text-sm italic -mt-2">
|
||||
<p>Should enter at least one of the cooling loads.</p>
|
||||
<p>Both are also acceptable.</p>
|
||||
</div>
|
||||
Registers<label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="2" id="registerCount">
|
||||
Room</label><label class="select w-full"><span class="label"></span>
|
||||
<select name="delegatedTo">
|
||||
<option selected disabled>Delegate Airflow</option>
|
||||
<option value="00000000-0000-0000-0000-000000000001">Bed-1</option>
|
||||
<option value="00000000-0000-0000-0000-000000000002">Entry</option>
|
||||
<option value="00000000-0000-0000-0000-000000000003">Family Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000004">Kitchen</option>
|
||||
<option value="00000000-0000-0000-0000-000000000005">Living Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000006">Master</option>
|
||||
</select></label>
|
||||
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -441,21 +559,50 @@ p-6 w-full">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="roomForm.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
<h1 class="text-3xl font-bold pb-6">Room</h1>
|
||||
<form class="grid grid-cols-1 gap-4" hx-post="/projects/00000000-0000-0000-0000-000000000000/rooms" hx-target="body" hx-swap="outerHTML">
|
||||
<input class="hidden" name="projectID" value="00000000-0000-0000-0000-000000000000">
|
||||
Name<label class="input w-full"><span class="label"></span>
|
||||
<label class="input w-full"><span class="label">Name</span>
|
||||
<input name="name" type="text" placeholder="Name" required autofocus value="">
|
||||
Heating Load</label><label class="input w-full"><span class="label"></span>
|
||||
Level</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="level" type="number" placeholder="1 (Optional)" value="" min="-1" step="1"></label>
|
||||
<div class="text-sm italic -mt-2"><span class="text-primary">Use -1 or 0 for a basement</span></div>
|
||||
Heating Load<label class="input w-full"><span class="label"></span>
|
||||
<input name="heatingLoad" type="number" placeholder="1234" required min="0" value="">
|
||||
Cooling Total</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingTotal" type="number" placeholder="1234" required min="0" value="">
|
||||
<input name="coolingTotal" type="number" placeholder="1234 (Optional)" min="0" value="">
|
||||
Cooling Sensible</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value="">
|
||||
Registers</label><label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="1"></label>
|
||||
<input name="coolingSensible" type="number" placeholder="1234 (Optional)" min="0" value=""></label>
|
||||
<div class="text-primary text-sm italic -mt-2">
|
||||
<p>Should enter at least one of the cooling loads.</p>
|
||||
<p>Both are also acceptable.</p>
|
||||
</div>
|
||||
Registers<label class="input w-full"><span class="label"></span>
|
||||
<input name="registerCount" type="number" min="1" required value="1" id="registerCount">
|
||||
Room</label><label class="select w-full"><span class="label"></span>
|
||||
<select name="delegatedTo">
|
||||
<option selected disabled>Delegate Airflow</option>
|
||||
<option value="00000000-0000-0000-0000-000000000001">Bed-1</option>
|
||||
<option value="00000000-0000-0000-0000-000000000002">Entry</option>
|
||||
<option value="00000000-0000-0000-0000-000000000003">Family Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000004">Kitchen</option>
|
||||
<option value="00000000-0000-0000-0000-000000000005">Living Room</option>
|
||||
<option value="00000000-0000-0000-0000-000000000006">Master</option>
|
||||
</select></label>
|
||||
<button class="btn btn-secondary btn-block" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
<dialog id="uploadCSV" class="modal">
|
||||
<div class="modal-box">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="uploadCSV.close()"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg></button>
|
||||
<div class="pb-6 space-y-3">
|
||||
<h1 class="text-3xl font-bold">Upload CSV</h1>
|
||||
<p class="text-sm italic">Drag and drop, or click to upload</p>
|
||||
</div>
|
||||
<form hx-post="/projects/00000000-0000-0000-0000-000000000000/rooms/csv" hx-target="body" hx-swap="outerHTML" enctype="multipart/form-data">
|
||||
<input type="file" name="file" accept=".csv">
|
||||
<button class="btn btn-secondary btn-block mt-6" type="submit">Submit</button>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -476,10 +623,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Project</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -495,10 +641,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Rooms</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -514,10 +659,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Equipment</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -533,10 +677,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>T.E.L.</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -552,10 +695,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Friction Rate</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -583,10 +725,25 @@ is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2
|
||||
</div>
|
||||
</main>
|
||||
<div class="bottom-0 left-0 bg-error">
|
||||
<footer class="footer sm:footer-horizontal footer-center
|
||||
<footer class="footer footer-horizontal footer-center
|
||||
bg-base-300 text-base-content p-4">
|
||||
<aside>
|
||||
<p>Copyright © 2026 - All rights reserved by Michael Housh</p>
|
||||
<aside class="grid-flow-row items-center">
|
||||
<div class="flex mx-auto">
|
||||
<a class="btn btn-ghost" href="mailto:support@ductcalc.pro"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
|
||||
</g>
|
||||
</svg><span>support@ductcalc.pro</span></a>
|
||||
</div>
|
||||
Openly licensed via CC-BY-NC-SA 4.0<a class="btn btn-ghost mx-auto" href="https://github.com/m-housh/swift-duct-calc/src/branch/main/LICENSE" target="_blank"></a>
|
||||
<p class="">Copyright © 2026 - All rights reserved by Michael Housh</p>
|
||||
</aside>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<script src="https://unpkg.com/htmx.org@2.0.8"></script>
|
||||
<script src="/js/htmx-download.js"></script>
|
||||
<script src="/js/main.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<link rel="stylesheet" href="/css/output.css">
|
||||
<link rel="stylesheet" href="/css/htmx.css">
|
||||
<link rel="icon" href="/images/favicon.ico" type="image/x-icon">
|
||||
@@ -28,22 +29,31 @@
|
||||
<script src="https://unpkg.com/htmx-remove@latest" crossorigin="anonymous" integrity="sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between">
|
||||
<div class="flex flex-col min-h-screen min-w-full justify-between" data-theme="default">
|
||||
<main class="flex flex-col min-h-screen min-w-full grow mb-auto">
|
||||
<div class="drawer lg:drawer-open h-full">
|
||||
<input id="my-drawer-1" type="checkbox" class="drawer-toggle">
|
||||
<div class="drawer-content overflow-auto">
|
||||
<nav class="navbar w-full bg-base-300 text-base-content shadow-sm mb-4">
|
||||
<div class="flex flex-1 space-x-4 items-center">
|
||||
<div class="tooltip tooltip-right" data-tip="Open sidebar"><label for="my-drawer-1" class="size-7 btn btn-square btn-ghost hover:bg-neutral hover:text-white" aria-label="open sidebar"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg></label></div>
|
||||
<div class="tooltip tooltip-right" data-tip="Home">
|
||||
<a class="flex w-fit h-fit text-xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/projects">
|
||||
<div class="tooltip tooltip-right" data-tip="Open / close sidebar"><label for="my-drawer-1" class="size-7 btn btn-square btn-ghost hover:bg-neutral hover:text-white" aria-label="open / close sidebar"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg></label></div>
|
||||
<div class="tooltip tooltip-right" data-tip="Projects">
|
||||
<a class="flex w-fit h-fit text-2xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/projects">
|
||||
<img src="/images/mand_logo_sm.webp">
|
||||
Duct Calc<span></span></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-none">
|
||||
<div class="tooltip tooltip-left" data-tip="Profile"><a href="/profile" class="btn btn-square btn-ghost hover:bg-neutral hover:text-white"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg></a></div>
|
||||
<div class="flex items-end space-x-4">
|
||||
<div class="tooltip tooltip-left" data-tip="Duct size calculator"><a class="btn btn-ghost btn-primary text-lg" href="/duct-size" target="_blank">Ductulator</a></div>
|
||||
<div class="dropdown dropdown-end dropdown-hover">
|
||||
<div class="btn m-1 btn btn-square btn-ghost hover:bg-neutral hover:text-white" tabindex="0" role="button"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg></div>
|
||||
<ul tabindex="-1" class="dropdown-content menu bg-base-200 rounded-box z-1 w-52 py-2 shadow-sm">
|
||||
<li><a href="/profile">Profile</a></li>
|
||||
<li><a href="/logout">Logout</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="p-4">
|
||||
@@ -119,10 +129,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Project</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -138,10 +147,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Rooms</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -157,10 +165,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Equipment</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -176,10 +183,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>T.E.L.</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -195,10 +201,9 @@ is-drawer-close:grid-cols-1">
|
||||
<div class="items-center
|
||||
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2">
|
||||
<div class="flex items-center justify-center is-drawer-close:text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg></div>
|
||||
<div class="flex items-center justify-center text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg></div>
|
||||
<div class="flex items-center justify-center"><span>Friction Rate</span></div>
|
||||
</div>
|
||||
<div class="flex grow justify-end items-end is-drawer-close:hidden text-green-400"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg></div>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
@@ -226,10 +231,25 @@ is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2
|
||||
</div>
|
||||
</main>
|
||||
<div class="bottom-0 left-0 bg-error">
|
||||
<footer class="footer sm:footer-horizontal footer-center
|
||||
<footer class="footer footer-horizontal footer-center
|
||||
bg-base-300 text-base-content p-4">
|
||||
<aside>
|
||||
<p>Copyright © 2026 - All rights reserved by Michael Housh</p>
|
||||
<aside class="grid-flow-row items-center">
|
||||
<div class="flex mx-auto">
|
||||
<a class="btn btn-ghost" href="mailto:support@ductcalc.pro"> <svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<g
|
||||
stroke-linejoin="round"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<rect width="20" height="16" x="2" y="4" rx="2"></rect>
|
||||
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"></path>
|
||||
</g>
|
||||
</svg><span>support@ductcalc.pro</span></a>
|
||||
</div>
|
||||
Openly licensed via CC-BY-NC-SA 4.0<a class="btn btn-ghost mx-auto" href="https://github.com/m-housh/swift-duct-calc/src/branch/main/LICENSE" target="_blank"></a>
|
||||
<p class="">Copyright © 2026 - All rights reserved by Michael Housh</p>
|
||||
</aside>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user