33 Commits

Author SHA1 Message Date
fe06508405 feat: Store timestamps as strings in the database, which was causing postgres errors.
Some checks failed
CI / Linux Tests (pull_request) Failing after 5m46s
2026-02-11 16:44:39 -05:00
f385fcdc28 fix: Reverts to inline on gitea, try non-inline on github. 2026-02-11 12:15:46 -05:00
af4d4e393a fix: Try without inline cache. 2026-02-11 12:05:18 -05:00
0f96c67058 fix: Try with inline cache mode. 2026-02-11 11:56:29 -05:00
fbee56e460 fix: Try without cache to ensure builds work. 2026-02-11 11:43:11 -05:00
a8286f9ce2 fix: Fix gitea build & push image. 2026-02-11 11:34:27 -05:00
e3a731e3fa fix: Fix gitea build & push image.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 11:23:10 -05:00
8e66d57994 fix: Fix gitea build & push image.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 11:03:53 -05:00
87c651eed9 fix: Fix gitea build & push image building mulitple images.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 10:50:14 -05:00
e8ab85a189 fix: Fix gitea build & push image building mulitple images.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 10:44:19 -05:00
1fcf331729 feat: Add image cache support to gitea workflow, for quicker image builds.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 10:41:39 -05:00
3f0a669b2b fix: Fix release workflow in gitea to not build multi platform images.
All checks were successful
CI / Linux Tests (push) Successful in 6m3s
2026-02-11 09:46:02 -05:00
07fff2cebd fix: Fix release workflow in gitea to not build multi platform images. 2026-02-11 09:45:12 -05:00
4b81d3bd1e fix: Fixes release workflow for multi-platform builds.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 09:01:10 -05:00
338ccc64df feat: Initial minimal docker compose and updates to release workflows.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-11 08:53:21 -05:00
729dc0ac55 feat: Updates to ci workflow files.
All checks were successful
CI / Linux Tests (push) Successful in 6m51s
2026-02-11 08:14:17 -05:00
86f08a4bb2 clean: Cleans up some files that aren't used.
All checks were successful
CI / Linux Tests (push) Successful in 5m51s
2026-02-10 17:02:01 -05:00
08e67ce308 feat: Adds privacy policy.
All checks were successful
CI / Linux Tests (push) Successful in 8m30s
2026-02-10 16:26:23 -05:00
dc9f51c04f feat: Adds level field to rooms, updates urls to point to public mirror of the project.
Some checks failed
CI / Linux Tests (push) Failing after 15s
2026-02-10 12:07:44 -05:00
980d99e40b feat: Adds ductulator button to logged in views.
All checks were successful
CI / Linux Tests (push) Successful in 5m46s
2026-02-09 16:58:28 -05:00
06b663052e feat: Renames quick calc routes / views to ductulator. Adds button to home page for using ductulator, needs added to navbar still. 2026-02-09 16:36:24 -05:00
007d13be2f feat: Adds quick calculation views, need to add buttons / links in navbar / home page. 2026-02-09 15:34:28 -05:00
88af6f722e feat: Adds logout route and switches user navbar item to dropdown menu. 2026-02-09 12:32:30 -05:00
5a7cf4714b feat: Adds multi-select option for selecting rooms in trunk sizing form. 2026-02-09 11:55:11 -05:00
e4ddec0d53 feat: Updates to home / landing page.
All checks were successful
CI / Linux Tests (push) Successful in 6m32s
2026-02-08 19:12:52 -05:00
bb88d48eb3 feat: Updates tests to include home page snapshot test, updates TODO's.
All checks were successful
CI / Linux Tests (push) Successful in 5m52s
2026-02-08 10:27:23 -05:00
d957cc1c19 feat: Adds minimal home page, change license to cc-by-nc-sa license in prep for public availablility.
Some checks failed
CI / Linux Tests (push) Failing after 5m41s
2026-02-08 10:14:19 -05:00
2aaa408712 fix: Fixes user not automatically being logged in upon creation. 2026-02-07 21:29:00 -05:00
291bed28d5 feat: Adds minimal cli executable and commands.
All checks were successful
CI / Linux Tests (push) Successful in 6m44s
2026-02-07 21:17:29 -05:00
1a38922ac0 fix: Fixes gitignore to not ignore rooms.csv test resource.
All checks were successful
CI / Linux Tests (push) Successful in 5m46s
2026-02-07 18:26:14 -05:00
76bd788769 feat: Adds createFromCSV to create rooms in the database, properly handling delegating airflow to another room.
Some checks failed
CI / Linux Tests (push) Failing after 5m29s
2026-02-07 18:16:01 -05:00
0134c9bfc2 WIP: Updates test html snapshots, working on validation when delegating airflow to a different room.
All checks were successful
CI / Linux Tests (push) Successful in 5m39s
2026-02-06 17:07:06 -05:00
0775474f57 WIP: Updates test html snapshots, working on validation when delegating airflow to a different room.
Some checks failed
CI / Linux Tests (push) Has been cancelled
2026-02-06 17:01:43 -05:00
83 changed files with 7384 additions and 4087 deletions

View File

@@ -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
View 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
View 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

2
.gitignore vendored
View File

@@ -14,4 +14,4 @@ tailwindcss
.env
.env*
default.profraw
rooms.csv
/rooms.csv

394
LICENSE
View File

@@ -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.

View File

@@ -1,5 +1,5 @@
{
"originHash" : "b6e6af1076a5bcce49e1231c44be25d770eaef278e2d1ce1c961446d49cb2d00",
"originHash" : "5bbd172c602e6484b32782f8cb68faca2d5120adc511acdff74e62fec2178c15",
"pins" : [
{
"identity" : "async-http-client",
@@ -172,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",
@@ -447,8 +456,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/m-housh/swift-validations.git",
"state" : {
"revision" : "95ea5d267e37f6cdb9f91c5c8a01e718b9299db6",
"version" : "0.3.4"
"revision" : "ae939c146f380ca12d0a04ca1f6b0c4c270fdd5a",
"version" : "0.3.5"
}
},
{

View File

@@ -6,6 +6,7 @@ let package = Package(
name: "swift-manual-d",
products: [
.executable(name: "App", targets: ["App"]),
.executable(name: "ductcalc", targets: ["CLI"]),
.library(name: "AuthClient", targets: ["AuthClient"]),
.library(name: "CSVParser", targets: ["CSVParser"]),
.library(name: "DatabaseClient", targets: ["DatabaseClient"]),
@@ -20,6 +21,7 @@ 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"),
@@ -34,7 +36,7 @@ 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.1.0"),
.package(url: "https://github.com/m-housh/swift-validations.git", from: "0.3.5"),
],
targets: [
.executableTarget(
@@ -54,6 +56,13 @@ let package = Package(
.product(name: "VaporRouting", package: "vapor-routing"),
]
),
.executableTarget(
name: "CLI",
dependencies: [
.target(name: "ManualDClient"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
.target(
name: "AuthClient",
dependencies: [
@@ -95,6 +104,9 @@ let package = Package(
.target(name: "DatabaseClient"),
.product(name: "DependenciesTestSupport", package: "swift-dependencies"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
],
resources: [
.copy("Resources")
]
),
.target(
@@ -156,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"),
]
),

View File

@@ -13,8 +13,12 @@
--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;
@@ -23,9 +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;
@@ -1684,6 +1696,9 @@
opacity: 40%;
}
}
.pointer-events-none {
pointer-events: none;
}
.react-day-picker {
@layer daisyui.l1.l2.l3 {
user-select: none;
@@ -4218,6 +4233,9 @@
.top-2 {
top: calc(var(--spacing) * 2);
}
.top-10 {
top: calc(var(--spacing) * 10);
}
.right-2 {
right: calc(var(--spacing) * 2);
}
@@ -4281,6 +4299,9 @@
.bottom-0 {
bottom: calc(var(--spacing) * 0);
}
.-left-15 {
left: calc(var(--spacing) * -15);
}
.left-0 {
left: calc(var(--spacing) * 0);
}
@@ -4673,6 +4694,9 @@
.z-1 {
z-index: 1;
}
.z-50 {
z-index: 50;
}
.tab-content {
@layer daisyui.l1.l2.l3 {
order: var(--tabcontent-order);
@@ -5225,6 +5249,9 @@
.m-1 {
margin: calc(var(--spacing) * 1);
}
.m-4 {
margin: calc(var(--spacing) * 4);
}
.m-6 {
margin: calc(var(--spacing) * 6);
}
@@ -5273,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;
}
@@ -5574,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;
@@ -5628,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%;
@@ -5690,6 +5738,9 @@
}
}
}
.mr-2 {
margin-right: calc(var(--spacing) * 2);
}
.fieldset-legend {
@layer daisyui.l1.l2.l3 {
margin-bottom: calc(0.25rem * -1);
@@ -5710,6 +5761,9 @@
font-weight: 600;
}
}
.mb-2 {
margin-bottom: calc(var(--spacing) * 2);
}
.mb-4 {
margin-bottom: calc(var(--spacing) * 4);
}
@@ -6434,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;
}
@@ -6443,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;
}
@@ -6574,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;
}
@@ -6583,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;
}
@@ -6601,6 +6721,12 @@
.flex-shrink {
flex-shrink: 1;
}
.flex-shrink-0 {
flex-shrink: 0;
}
.shrink-0 {
flex-shrink: 0;
}
.flex-grow {
flex-grow: 1;
}
@@ -6623,6 +6749,12 @@
}
}
}
.-rotate-45 {
rotate: calc(45deg * -1);
}
.rotate-180 {
rotate: 180deg;
}
.swap-flip {
@layer daisyui.l1.l2 {
transform-style: preserve-3d;
@@ -6678,6 +6810,12 @@
}
}
}
.cursor-not-allowed {
cursor: not-allowed;
}
.cursor-pointer {
cursor: pointer;
}
.resize {
resize: both;
}
@@ -6716,6 +6854,9 @@
}
}
}
.list-disc {
list-style-type: disc;
}
.alert-horizontal {
@layer daisyui.l1.l2 {
justify-content: start;
@@ -6782,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));
}
@@ -6794,6 +6941,9 @@
.flex-col {
flex-direction: column;
}
.flex-row {
flex-direction: row;
}
.flex-wrap {
flex-wrap: wrap;
}
@@ -6818,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;
@@ -6841,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;
@@ -6855,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;
@@ -6882,6 +7041,9 @@
.overflow-auto {
overflow: auto;
}
.overflow-hidden {
overflow: hidden;
}
.timeline-box {
@layer daisyui.l1.l2.l3 {
border: var(--border) solid;
@@ -6964,6 +7126,12 @@
}
}
}
.rounded {
border-radius: 0.25rem;
}
.rounded-3xl {
border-radius: var(--radius-3xl);
}
.rounded-box {
border-radius: var(--radius-box);
}
@@ -6976,9 +7144,15 @@
.rounded-field {
border-radius: var(--radius-field);
}
.rounded-full {
border-radius: calc(infinity * 1px);
}
.rounded-lg {
border-radius: var(--radius-lg);
}
.rounded-md {
border-radius: var(--radius-md);
}
.rounded-selector {
border-radius: var(--radius-selector);
}
@@ -7164,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);
@@ -7277,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);
}
@@ -7286,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 {
@@ -7437,9 +7628,15 @@
}
}
}
.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);
}
@@ -7658,6 +7855,9 @@
.mask-repeat {
mask-repeat: repeat;
}
.stroke-current {
stroke: currentcolor;
}
.checkbox-lg {
@layer daisyui.l1.l2 {
padding: 0.3125rem;
@@ -7728,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);
@@ -7854,12 +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);
}
.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);
}
@@ -7883,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);
@@ -7931,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));
@@ -7943,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);
@@ -8497,6 +8765,9 @@
color: var(--color-warning);
}
}
.text-accent {
color: var(--color-accent);
}
.text-base-content {
color: var(--color-base-content);
}
@@ -8512,6 +8783,9 @@
.text-info {
color: var(--color-info);
}
.text-primary {
color: var(--color-primary);
}
.text-success {
color: var(--color-success);
}
@@ -8576,13 +8850,26 @@
}
}
}
.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);
}
.shadow-lg {
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
.shadow-sm {
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -8627,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);
@@ -9382,6 +9679,44 @@
}
}
}
.hover\:opacity-75 {
&:hover {
@media (hover: hover) {
opacity: 75%;
}
}
}
.hover\:opacity-100 {
&:hover {
@media (hover: hover) {
opacity: 100%;
}
}
}
.focus\:\!bg-transparent {
&:focus {
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 {
&[data-active] {
background-color: var(--color-neutral);
@@ -9402,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 {
@@ -9465,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 {
@@ -9509,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;
@@ -11101,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 {
@@ -11156,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;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,2 @@
# swift-manual-d
# swift-duct-calc

View File

@@ -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

View File

@@ -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
View 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
)
}

View 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)")
}
}

View 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)
"""
)
}
}

View File

@@ -12,7 +12,7 @@ extension DependencyValues {
@DependencyClient
public struct CSVParser: Sendable {
public var parseRooms: @Sendable (Room.CSV) async throws -> [Room.Create]
public var parseRooms: @Sendable (Room.CSV) async throws -> [Room.CSV.Row]
}
extension CSVParser: DependencyKey {
@@ -24,12 +24,11 @@ extension CSVParser: DependencyKey {
throw CSVParsingError("Unreadable file data")
}
let rows = try RoomCSVParser().parse(string[...].utf8)
let rooms = rows.reduce(into: [Room.Create]()) {
return rows.reduce(into: [Room.CSV.Row]()) {
if case .room(let room) = $1 {
$0.append(room)
}
}
return rooms
}
)
}

View File

@@ -25,7 +25,7 @@ struct RoomRowParser: Parser {
enum RoomRowType {
case header(String)
case room(Room.Create)
case room(Room.CSV.Row)
}
struct RoomCreateParser: ParserPrinter {
@@ -34,10 +34,15 @@ struct RoomCreateParser: ParserPrinter {
// 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.Create> {
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 {
@@ -51,9 +56,9 @@ struct RoomCreateParser: ParserPrinter {
Int.parser()
",".utf8
Optionally {
Room.ID.parser()
Prefix { $0 != UInt8(ascii: "\n") }.map(.string)
}
}
.map(.memberwise(Room.Create.init))
.map(.memberwise(Room.CSV.Row.init))
}
}

View File

@@ -91,6 +91,7 @@ public struct DatabaseClient: Sendable {
public struct Rooms: Sendable {
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
@@ -98,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

View File

@@ -66,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)
)

View File

@@ -73,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)
)

View File

@@ -90,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)
)

View File

@@ -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(),

View File

@@ -120,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()

View File

@@ -16,11 +16,61 @@ extension DatabaseClient.Rooms: TestDependencyKey {
return try model.toDTO()
},
createMany: { projectID, rooms in
try await rooms.asyncMap { request in
let model = try request.toModel(projectID: projectID)
try await model.validateAndSave(on: database)
return try model.toDTO()
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 {
@@ -81,11 +131,49 @@ extension DatabaseClient.Rooms: TestDependencyKey {
}
}
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,
delegatedTo: nil
)
}
}
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.Create {
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,
@@ -103,13 +191,14 @@ extension Room {
try await database.schema(RoomModel.schema)
.id()
.field("name", .string, .required)
.field("level", .int8)
.field("heatingLoad", .double, .required)
.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)
)
@@ -133,6 +222,9 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
@Field(key: "name")
var name: String
@Field(key: "level")
var level: Int?
@Field(key: "heatingLoad")
var heatingLoad: Double
@@ -162,6 +254,7 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
init(
id: UUID? = nil,
name: String,
level: Int? = nil,
heatingLoad: Double,
coolingLoad: Room.CoolingLoad,
registerCount: Int,
@@ -173,6 +266,7 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
) {
self.id = id
self.name = name
self.level = level
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.registerCount = registerCount
@@ -188,6 +282,7 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
id: requireID(),
projectID: $project.id,
name: name,
level: level.map(Room.Level.init(rawValue:)),
heatingLoad: heatingLoad,
coolingLoad: coolingLoad,
registerCount: registerCount,
@@ -203,6 +298,9 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
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
}
@@ -229,13 +327,40 @@ final class RoomModel: Model, @unchecked Sendable, Validatable {
Validator.validate(\.coolingLoad)
.errorLabel("Cooling Load", inline: true)
Validator.validate(\.registerCount, with: .greaterThanOrEquals(1))
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 {

View File

@@ -81,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()
}

View File

@@ -76,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()
}
@@ -97,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()
}

View File

@@ -63,9 +63,9 @@ public struct EnvVars: Codable, Equatable, Sendable {
case pandocPath = "PANDOC_PATH"
case pdfEngine = "PDF_ENGINE"
case postgresHostname = "POSTGRES_HOSTNAME"
case postgresUsername = "POSTGRES_USERNAME"
case postgresUsername = "POSTGRES_USER"
case postgresPassword = "POSTGRES_PASSWORD"
case postgresDatabase = "POSTGRES_DATABASE"
case postgresDatabase = "POSTGRES_DB"
case sqlitePath = "SQLITE_PATH"
}

View File

@@ -1,50 +1,6 @@
import Foundation
import ManualDCore
extension Room {
public var heatingLoadPerRegister: Double {
heatingLoad / Double(registerCount)
}
public func coolingSensiblePerRegister(projectSHR: Double) throws -> Double {
let sensible = try coolingLoad.ensured(shr: projectSHR).sensible
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) throws -> Double {
try room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
}
}
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) }
}
}
extension Array where Element == EffectiveLengthGroup {
var totalEffectiveLength: Int {
reduce(0) { $0 + $1.effectiveLength }

View File

@@ -1,5 +1,6 @@
import Dependencies
import Foundation
import Tagged
/// Represents a room in a project.
///
@@ -17,6 +18,9 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
/// 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
@@ -45,6 +49,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
id: UUID,
projectID: Project.ID,
name: String,
level: Level? = nil,
heatingLoad: Double,
coolingLoad: CoolingLoad,
registerCount: Int = 1,
@@ -56,6 +61,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
self.id = id
self.projectID = projectID
self.name = name
self.level = level
self.heatingLoad = heatingLoad
self.coolingLoad = coolingLoad
self.registerCount = registerCount
@@ -98,6 +104,11 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
}
}
public enum LevelTag {}
public typealias Level = Tagged<LevelTag, Int>
}
extension Room {
@@ -106,6 +117,9 @@ extension Room {
/// 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
@@ -127,6 +141,7 @@ extension Room {
public init(
name: String,
level: Room.Level? = nil,
heatingLoad: Double,
coolingTotal: Double? = nil,
coolingSensible: Double? = nil,
@@ -134,6 +149,7 @@ extension Room {
delegatedTo: Room.ID? = nil
) {
self.name = name
self.level = level
self.heatingLoad = heatingLoad
self.coolingTotal = coolingTotal
self.coolingSensible = coolingSensible
@@ -148,6 +164,57 @@ extension Room {
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
@@ -180,6 +247,10 @@ extension Room {
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).
@@ -200,12 +271,14 @@ extension Room {
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
@@ -217,6 +290,7 @@ extension Room {
rectangularSizes: [RectangularSize]
) {
self.name = nil
self.level = nil
self.heatingLoad = nil
self.coolingTotal = nil
self.coolingSensible = nil
@@ -258,6 +332,16 @@ public struct CoolingLoadError: Error, Equatable, Sendable {
}
}
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 {

View File

@@ -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
}
@@ -208,12 +221,6 @@ extension SiteRoute.View.ProjectRoute {
}
Method.post
Body().map(.memberwise(Room.CSV.init))
// Body {
// FormData {
//
// }
// .map(.memberwise(Room.CSV.init))
// }
}
Route(.case(Self.delete)) {
Path {
@@ -234,6 +241,12 @@ extension SiteRoute.View.ProjectRoute {
Body {
FormData {
Field("name", .string)
Optionally {
Field("level") {
Int.parser()
}
.map(.memberwise(Room.Level.init(rawValue:)))
}
Field("heatingLoad") { Double.parser() }
Optionally {
Field("coolingTotal") { Double.parser() }
@@ -260,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() }
}
@@ -895,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
}
}
}
}
@@ -977,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

View File

@@ -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,
@@ -42,10 +43,15 @@ extension ManualDClient {
var retval: [DuctSizes.RoomContainer] = []
let totalHeatingLoad = rooms.totalHeatingLoad
let totalCoolingSensible = try rooms.totalCoolingSensible(shr: sharedRequest.projectSHR)
let nonDelegatedRooms = rooms.filter { $0.delegatedTo == nil }
for room in rooms {
let heatingLoad = room.heatingLoadPerRegister
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)
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 {
//
// var heatingLoadPerRegister: Double {
//
// heatingLoad / Double(registerCount)
// // 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
// }
//
// func coolingSensiblePerRegister(projectSHR: Double) -> Double {
// let sensible = coolingSensible ?? (coolingTotal * projectSHR)
// return sensible / Double(registerCount)
// var totalHeatingLoad: Double {
// room.heatingLoadPerRegister() * Double(actualRegisterCount)
// }
//
// func totalCoolingSensible(projectSHR: Double) throws -> Double {
// try room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
// }
// }
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)
}
}
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) }
}
}
// 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) }
// }
// }

View 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)
}
}

View 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) }
}
}

View File

@@ -1,4 +1,6 @@
import Elementary
import ElementaryHTMX
import ManualDCore
public struct SubmitButton: HTML, Sendable {
let title: String
@@ -74,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"
}
}
}

View 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 {}

View File

@@ -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)"
}
}
}
}

View File

@@ -17,6 +17,7 @@ extension SVG {
public enum Key: Sendable {
case badgeCheck
case ban
case calculator
case chevronDown
case chevronRight
case chevronsLeft
@@ -48,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>

View File

@@ -59,3 +59,24 @@ extension Select where Element: Identifiable, Element.ID == UUID, Element: Senda
}
}
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]) }
)
}
}

View File

@@ -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()

View File

@@ -3,6 +3,7 @@ import DatabaseClient
import Dependencies
import Elementary
import Foundation
import ManualDClient
import ManualDCore
import PdfClient
import ProjectClient
@@ -17,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 {
@@ -34,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):
@@ -89,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)
}
@@ -100,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
}
}
@@ -293,7 +307,7 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
case .csv(let csv):
return await roomsView(on: request, projectID: projectID) {
let rooms = try await csvParser.parseRooms(csv)
_ = try await database.rooms.createMany(projectID, rooms)
_ = try await database.rooms.createFromCSV(projectID, rooms)
}
// return EmptyHTML()
@@ -601,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)
@@ -642,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)
}
@@ -690,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)
}
}
}
}

View File

@@ -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()

View 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)" }
}
}
}
}
}
}
}
}

View File

@@ -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"

View 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"
}
}
}
}
}
}

View File

@@ -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"
}
}

View File

@@ -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")
}
}
}

View 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"
}
}
}
}
}
}

View File

@@ -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)
// }
}
}
}

View File

@@ -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" }

View File

@@ -30,13 +30,17 @@ struct RoomForm: HTML, Sendable {
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" }
@@ -64,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"),
@@ -74,8 +93,6 @@ struct RoomForm: HTML, Sendable {
.value(room?.heatingLoad)
)
// TODO: Add description that only one is required (cooling total or sensible)
LabeledInput(
"Cooling Total",
.name("coolingTotal"),
@@ -93,6 +110,14 @@ struct RoomForm: HTML, Sendable {
.min("0"),
.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",
@@ -104,28 +129,15 @@ struct RoomForm: HTML, Sendable {
.id("registerCount")
)
label(.class("select w-full"), .id("delegateToSelect")) {
label(.class("select w-full")) {
span(.class("label")) { "Room" }
Select(rooms, placeholder: "Delegate Airflow") {
$0.name
}
.attributes(.name("delegatedTo"))
Select(selectableRooms, label: \.name, placeholder: "Delegate Airflow")
.attributes(.name("delegatedTo"))
}
SubmitButton()
.attributes(.class("btn-block"))
}
}
script {
"""
function myClick() {
console.log('clicked');
const simple = document.getElementById('simple');
console.log(simple.style.display);
simple.style.display = 'block';
console.log(simple.style.display);
}
"""
}
}
}

View File

@@ -15,13 +15,16 @@ struct RoomsView: HTML, Sendable {
.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")) {
input(.type(.checkbox), .name("delegateToCheckbox"), .on(.click, "showElement('simple');"))
div(.style("display: none;"), .id("simple"), .class("hidden")) {
"This is hidden"
}
PageTitleRow {
div(.class("flex grid grid-cols-3 w-full gap-y-4")) {
@@ -107,6 +110,11 @@ struct RoomsView: HTML, Sendable {
"Register Count"
}
}
th {
div(.class("flex justify-center")) {
"Delegated To"
}
}
th {
div(.class("flex justify-end me-2 space-x-4")) {
@@ -133,7 +141,7 @@ struct RoomsView: HTML, Sendable {
}
}
tbody {
for room in rooms {
for room in sortedRooms {
RoomRow(room: room, shr: sensibleHeatRatio, rooms: rooms)
}
}
@@ -151,10 +159,11 @@ struct RoomsView: HTML, Sendable {
var coolingSensible: Double {
try! room.coolingLoad.ensured(shr: shr).sensible
// guard let value = room.coolingSensible else {
// return room.coolingTotal * shr
// }
// return value
}
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]) {
@@ -165,7 +174,13 @@ struct RoomsView: HTML, Sendable {
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)
@@ -186,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 {

View File

@@ -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)) {
}
}
}
}

View File

@@ -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")) {

View File

@@ -150,6 +150,10 @@ struct UserProfileForm: HTML, Sendable {
.attributes(.class("btn-block"))
}
.attributes(
.hx.pushURL("/projects"),
when: signup == true
)
}
}
}

View File

@@ -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 {

24
TODO.md
View File

@@ -3,16 +3,24 @@
- [x] Fix theme not working when selected upon signup.
- [x] Pdf generation
- [x] 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
- [ ] Add ability for either sensible or total load while specifying a room load.
- [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.
- [ ] Add ability to associate room load / airflow with another room.
- [ ] Trunk size form, room / register selection is wonky when labels are long.
- [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.
- [ ] Add select all rooms for trunks, useful for sizing main supply or return trunks.
- [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.

View File

@@ -11,12 +11,15 @@ struct CSVParsingTests {
let parser = CSVParser.liveValue
let input = """
Name,Heating Load,Cooling Total,Cooling Sensible,Register Count
Bed-1,12345,12345,,2
Bed-2,1223,,1123,1
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 == 2)
#expect(rooms.count == 4)
#expect(rooms.first!.level == 2)
}
}

View File

@@ -1,4 +1,5 @@
import App
import CSVParser
import DatabaseClient
import Dependencies
import Fluent

View 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
1 Name Level Heating Load Cooling Total Cooling Sensible Register Count Delegated To
2 Bed-1 2 2345 1234 1321 1
3 Entry 1 3456 2345 1234 1
4 Kitchen 1 7654 3456 2453 2
5 Bath-1 1 890 345 0 Kitchen

View File

@@ -1,4 +1,6 @@
import CSVParser
import Dependencies
import FileClient
import Foundation
import ManualDCore
import Parsing
@@ -63,6 +65,28 @@ struct RoomTests {
}
}
@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 {
@@ -157,4 +181,3 @@ struct RoomTests {
}
}
}

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">
@@ -63,12 +73,6 @@ p-6 w-full">
</div>
</button>
</div>
<div class="tooltip tooltip-left" data-tip="Upload csv file">
<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" type="submit">Submit</button>
</form>
</div>
</div>
<div class="flex items-end space-x-4 font-bold">
<span class="text-lg">Heating Total</span>
@@ -113,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>
@@ -136,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">
@@ -160,14 +171,31 @@ p-6 w-full">
<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 (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>
@@ -188,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">
@@ -212,14 +241,31 @@ p-6 w-full">
<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 (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>
@@ -240,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">
@@ -264,14 +311,31 @@ p-6 w-full">
<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 (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>
@@ -292,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">
@@ -316,14 +381,31 @@ p-6 w-full">
<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 (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>
@@ -344,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">
@@ -368,14 +451,31 @@ p-6 w-full">
<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 (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>
@@ -396,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">
@@ -420,14 +521,31 @@ p-6 w-full">
<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 (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>
@@ -443,18 +561,48 @@ p-6 w-full">
<form class="grid grid-cols-1 gap-4" hx-post="/projects/00000000-0000-0000-0000-000000000000/rooms" hx-target="body" hx-swap="outerHTML">
<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 (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>
@@ -475,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>
@@ -494,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>
@@ -513,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>
@@ -532,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>
@@ -551,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>
@@ -582,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>

View File

@@ -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>

View File

@@ -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">
@@ -249,10 +259,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>
@@ -268,10 +277,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>
@@ -287,10 +295,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>
@@ -306,10 +313,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>
@@ -325,10 +331,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>
@@ -356,10 +361,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>

View File

@@ -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">
@@ -325,10 +335,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>
@@ -344,10 +353,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>
@@ -363,10 +371,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>
@@ -382,10 +389,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>
@@ -401,10 +407,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>
@@ -432,10 +437,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>

View File

@@ -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">
@@ -926,6 +936,7 @@ p-6 w-full">
</div>
</div>
</div>
<script src="/js/daisy-multiselect.js"></script>
<dialog id="trunkSizeForm_0000000000000000000000000000000F" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="trunkSizeForm_0000000000000000000000000000000F.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>
@@ -942,131 +953,23 @@ p-6 w-full">
<input type="text" name="height" value="" placeholder="8 (Optional)"></label>
</div>
Name<label class="input w-full"><span class="label"></span>
<input type="text" name="name" value="" placeholder="Trunk-1 (Optional)"></label>
<input type="text" name="name" value="" placeholder="Trunk-1" required></label>
<div>
<h2 class="label font-bold col-span-3 mb-6">Associated Supply Runs</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 justify-center items-center gap-4">
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Bed-1</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000001_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Entry</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000002_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Entry</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000002_2" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Family Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000003_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Family Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000003_2" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Family Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000003_3" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Kitchen</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000004_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Kitchen</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000004_2" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Living Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000005_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Living Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000005_2" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Master</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000006_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Master</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000006_2" checked>
</div>
</div>
</div>
</div>
<daisy-multiselect class="z-50 bg-base-200" placeholder="Select rooms" name="rooms" chip-style show-select-all show-clear required virtual-scroll>
<option value="00000000-0000-0000-0000-000000000001_1" selected>Bed-1</option>
<option value="00000000-0000-0000-0000-000000000002_1" selected>Entry</option>
<option value="00000000-0000-0000-0000-000000000002_2" selected>Entry</option>
<option value="00000000-0000-0000-0000-000000000003_1" selected>Family Room</option>
<option value="00000000-0000-0000-0000-000000000003_2" selected>Family Room</option>
<option value="00000000-0000-0000-0000-000000000003_3" selected>Family Room</option>
<option value="00000000-0000-0000-0000-000000000004_1" selected>Kitchen</option>
<option value="00000000-0000-0000-0000-000000000004_2" selected>Kitchen</option>
<option value="00000000-0000-0000-0000-000000000005_1" selected>Living Room</option>
<option value="00000000-0000-0000-0000-000000000005_2" selected>Living Room</option>
<option value="00000000-0000-0000-0000-000000000006_1" selected>Master</option>
<option value="00000000-0000-0000-0000-000000000006_2" selected>Master</option>
</daisy-multiselect>
</div>
<button class="btn btn-secondary btn-block mt-6" type="submit">Submit</button>
</form>
@@ -1128,6 +1031,7 @@ p-6 w-full">
</div>
</div>
</div>
<script src="/js/daisy-multiselect.js"></script>
<dialog id="trunkSizeForm_00000000000000000000000000000010" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="trunkSizeForm_00000000000000000000000000000010.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>
@@ -1144,131 +1048,23 @@ p-6 w-full">
<input type="text" name="height" value="" placeholder="8 (Optional)"></label>
</div>
Name<label class="input w-full"><span class="label"></span>
<input type="text" name="name" value="" placeholder="Trunk-1 (Optional)"></label>
<input type="text" name="name" value="" placeholder="Trunk-1" required></label>
<div>
<h2 class="label font-bold col-span-3 mb-6">Associated Supply Runs</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 justify-center items-center gap-4">
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Bed-1</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000001_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Entry</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000002_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Entry</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000002_2" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Family Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000003_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Family Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000003_2" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Family Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000003_3" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Kitchen</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000004_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Kitchen</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000004_2" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Living Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000005_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Living Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000005_2" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Master</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000006_1" checked>
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Master</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000006_2" checked>
</div>
</div>
</div>
</div>
<daisy-multiselect class="z-50 bg-base-200" placeholder="Select rooms" name="rooms" chip-style show-select-all show-clear required virtual-scroll>
<option value="00000000-0000-0000-0000-000000000001_1" selected>Bed-1</option>
<option value="00000000-0000-0000-0000-000000000002_1" selected>Entry</option>
<option value="00000000-0000-0000-0000-000000000002_2" selected>Entry</option>
<option value="00000000-0000-0000-0000-000000000003_1" selected>Family Room</option>
<option value="00000000-0000-0000-0000-000000000003_2" selected>Family Room</option>
<option value="00000000-0000-0000-0000-000000000003_3" selected>Family Room</option>
<option value="00000000-0000-0000-0000-000000000004_1" selected>Kitchen</option>
<option value="00000000-0000-0000-0000-000000000004_2" selected>Kitchen</option>
<option value="00000000-0000-0000-0000-000000000005_1" selected>Living Room</option>
<option value="00000000-0000-0000-0000-000000000005_2" selected>Living Room</option>
<option value="00000000-0000-0000-0000-000000000006_1" selected>Master</option>
<option value="00000000-0000-0000-0000-000000000006_2" selected>Master</option>
</daisy-multiselect>
</div>
<button class="btn btn-secondary btn-block mt-6" type="submit">Submit</button>
</form>
@@ -1278,6 +1074,7 @@ p-6 w-full">
</tr>
</tbody>
</table>
<script src="/js/daisy-multiselect.js"></script>
<dialog id="trunkSizeForm" class="modal">
<div class="modal-box">
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2" onclick="trunkSizeForm.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>
@@ -1294,131 +1091,23 @@ p-6 w-full">
<input type="text" name="height" value="" placeholder="8 (Optional)"></label>
</div>
Name<label class="input w-full"><span class="label"></span>
<input type="text" name="name" value="" placeholder="Trunk-1 (Optional)"></label>
<input type="text" name="name" value="" placeholder="Trunk-1" required></label>
<div>
<h2 class="label font-bold col-span-3 mb-6">Associated Supply Runs</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 justify-center items-center gap-4">
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Bed-1</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000001_1">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Entry</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000002_1">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Entry</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000002_2">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Family Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000003_1">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Family Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000003_2">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Family Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000003_3">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Kitchen</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000004_1">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Kitchen</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000004_2">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Living Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000005_1">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Living Room</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000005_2">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Master</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000006_1">
</div>
</div>
</div>
<div class="block grow">
<div class="grid grid-cols-1 space-y-1">
<div class="flex justify-center">
<p class="label">Master</p>
</div>
<div class="flex justify-center">
<input class="checkbox" type="checkbox" name="rooms" value="00000000-0000-0000-0000-000000000006_2">
</div>
</div>
</div>
</div>
<daisy-multiselect class="z-50 bg-base-200" placeholder="Select rooms" name="rooms" chip-style show-select-all show-clear required virtual-scroll>
<option value="00000000-0000-0000-0000-000000000001_1">Bed-1</option>
<option value="00000000-0000-0000-0000-000000000002_1">Entry</option>
<option value="00000000-0000-0000-0000-000000000002_2">Entry</option>
<option value="00000000-0000-0000-0000-000000000003_1">Family Room</option>
<option value="00000000-0000-0000-0000-000000000003_2">Family Room</option>
<option value="00000000-0000-0000-0000-000000000003_3">Family Room</option>
<option value="00000000-0000-0000-0000-000000000004_1">Kitchen</option>
<option value="00000000-0000-0000-0000-000000000004_2">Kitchen</option>
<option value="00000000-0000-0000-0000-000000000005_1">Living Room</option>
<option value="00000000-0000-0000-0000-000000000005_2">Living Room</option>
<option value="00000000-0000-0000-0000-000000000006_1">Master</option>
<option value="00000000-0000-0000-0000-000000000006_2">Master</option>
</daisy-multiselect>
</div>
<button class="btn btn-secondary btn-block mt-6" type="submit">Submit</button>
</form>
@@ -1444,10 +1133,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>
@@ -1463,10 +1151,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>
@@ -1482,10 +1169,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>
@@ -1501,10 +1187,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>
@@ -1520,10 +1205,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>
@@ -1551,10 +1235,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>

View File

@@ -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,19 +29,28 @@
<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>
<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-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="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="m-6">
@@ -101,10 +111,25 @@ p-6 w-full pb-6">
</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>

View File

@@ -0,0 +1,77 @@
<!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-xl 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>
</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">Duct Size</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 sm:footer-horizontal footer-center
bg-base-300 text-base-content p-4">
<aside>
<p>Copyright © 2026 - All rights reserved by Michael Housh</p>
Openly licensed via CC-BY-NC-SA 4.0<a class="btn btn-ghost" href="https://git.housh.dev/michael/swift-duct-calc/src/branch/main/LICENSE" target="_blank"></a>
</aside>
</footer>
</div>
</div>
</body>
</html>

View File

@@ -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">

View File

@@ -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,17 +29,22 @@
<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>
<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-xl items-end px-4 py-2 btn btn-square btn-ghost hover:bg-neutral hover:text-white" href="/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="/">
<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="p-4">
<div class="flex justify-between">
@@ -149,10 +155,25 @@
</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>

31
docker-compose.yaml Normal file
View File

@@ -0,0 +1,31 @@
services:
db:
image: docker.io/postgres:18
restart: unless-stopped
env_file: .env
volumes:
- ./data:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ductcalc"]
interval: 5s
timeout: 5s
retries: 5
app:
build:
dockerfile: docker/Dockerfile
context: .
restart: unless-stopped
env_file: .env
environment:
- POSTGRES_HOSTNAME=db
depends_on:
db:
condition: healthy
ports:
- 8081:8080
healthcheck:
test: curl --fail --silent http://0.0.0.0:8080/health || exit 1
interval: 1m
timeout: 10s
retries: 3

View File

@@ -1,49 +0,0 @@
FROM docker.io/swift:6.2-noble
# Make sure all system packages are up to date, and install only essential packages.
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
&& apt-get -q update \
&& apt-get -q dist-upgrade -y \
&& apt-get -q install -y \
libjemalloc2 \
ca-certificates \
tzdata \
# If your app or its dependencies import FoundationNetworking, also install `libcurl4`.
libcurl4 \
# If your app or its dependencies import FoundationXML, also install `libxml2`.
# libxml2 \
sqlite3 \
nodejs \
npm \
build-essential \
curl \
wkhtmltopdf \
pandoc \
weasyprint \
&& rm -r /var/lib/apt/lists/*
# Set up a build area
WORKDIR /app
# First just resolve dependencies.
# This creates a cached layer that can be reused
# as long as your Package.swift/Package.resolved
# files do not change.
COPY ./Package.* ./
RUN swift package resolve \
$([ -f ./Package.resolved ] && echo "--force-resolved-versions" || true)
# Copy entire repo into container
COPY . .
RUN curl -L https://github.com/watchexec/watchexec/releases/download/v2.3.2/watchexec-2.3.2-aarch64-unknown-linux-gnu.tar.xz --output watchexec.tar.xz \
&& tar -xvf watchexec.tar.xz \
&& cp ./watchexec-2.3.2-aarch64-unknown-linux-gnu/watchexec /bin
RUN npm install -g browser-sync
ENV SWIFT_BACKTRACE=enable=no
ENV LOG_LEVEL=debug
CMD ["swift", "run", "App", "serve", "--hostname", "0.0.0.0", "--port", "8080"]

View File

@@ -0,0 +1,23 @@
services:
db:
image: docker.io/postgres:18
restart: unless-stopped
env_file: .env
volumes:
- ./data:/var/lib/postgresql
app:
build:
dockerfile: docker/Dockerfile
context: .
restart: unless-stopped
env_file: .env
depends_on:
- db
ports:
- 8080:8080
healthcheck:
test: curl --fail --silent http://0.0.0.0:8080/health || exit 1
interval: 1m
timeout: 10s
retries: 3

20
docker/example.env Normal file
View File

@@ -0,0 +1,20 @@
# Shared with database & app
POSTGRES_USER=ductcalc
POSTGRES_PASSWORD=super-secret-change-me
POSTGRES_DB=ductcalc
# App only
#
POSTGRES_HOSTNAME=db
# If using sqlite not postgres
#SQLITE_PATH=db.sqlite
# Set the pdf engine to use, this generally does not
# need set, unless extending the base image.
#PDF_ENGINE=weasyprint
# Set the path to the pandoc executable. This generally
# does not need set, unless extending the base image.
#PANDOC_PATH=/usr/bin/pandoc

View File

@@ -1,359 +0,0 @@
{
"trunks": [
{
"trunk": {
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"type": "supply",
"name": "West",
"id": "AFBC5264-8129-4383-97D8-F44CF5258A53",
"rooms": [
{
"room": {
"coolingTotal": 4567,
"id": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"registerCount": 3,
"heatingLoad": 9876,
"updatedAt": "2026-01-17T16:51:33Z",
"createdAt": "2026-01-17T16:51:33Z",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"name": "Kitchen"
},
"registers": [1, 2, 3]
},
{
"room": {
"coolingTotal": 4567,
"id": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"registerCount": 3,
"heatingLoad": 9876,
"updatedAt": "2026-01-17T16:51:21Z",
"createdAt": "2026-01-17T16:51:21Z",
"name": "Master",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
},
"registers": [1, 2, 3]
}
],
"height": 8
},
"ductSize": {
"height": 8,
"roundSize": 12.3660960368007,
"width": 19,
"velocity": 737,
"finalSize": 14,
"flexSize": 14,
"designCFM": {
"cooling": {
"_0": 787.278055507671
}
}
}
},
{
"trunk": {
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"name": "East",
"id": "C6A21594-0A28-4A20-BC15-628502010201",
"type": "supply",
"height": 8,
"rooms": [
{
"room": {
"coolingTotal": 1234,
"id": "2420F9C7-0FCA-4E92-BAC9-8A054CB3201B",
"registerCount": 1,
"heatingLoad": 4567,
"updatedAt": "2026-01-17T16:51:10Z",
"createdAt": "2026-01-17T16:51:10Z",
"name": "Entry",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
},
"registers": [1]
},
{
"room": {
"coolingTotal": 1234,
"id": "449FE324-2ACF-4C12-83A3-EB86FD45A991",
"registerCount": 2,
"heatingLoad": 4567,
"updatedAt": "2026-01-17T16:51:02Z",
"createdAt": "2026-01-17T16:51:02Z",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"name": "Bed-1"
},
"registers": [1, 2]
}
]
},
"ductSize": {
"height": 8,
"roundSize": 8.39504804369037,
"width": 8,
"velocity": 643,
"flexSize": 10,
"finalSize": 9,
"designCFM": {
"heating": {
"_0": 284.587689538185
}
}
}
},
{
"trunk": {
"name": "Main",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663",
"id": "C65863A0-D2EE-4D90-8C71-B1FD2454A8DF",
"type": "return",
"rooms": [
{
"registers": [1, 2, 3],
"room": {
"coolingTotal": 4567,
"id": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"registerCount": 3,
"heatingLoad": 9876,
"updatedAt": "2026-01-17T16:51:33Z",
"createdAt": "2026-01-17T16:51:33Z",
"name": "Kitchen",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
}
},
{
"registers": [1, 2, 3],
"room": {
"coolingTotal": 4567,
"id": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"registerCount": 3,
"heatingLoad": 9876,
"updatedAt": "2026-01-17T16:51:21Z",
"createdAt": "2026-01-17T16:51:21Z",
"name": "Master",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
}
},
{
"registers": [1],
"room": {
"coolingTotal": 1234,
"id": "2420F9C7-0FCA-4E92-BAC9-8A054CB3201B",
"registerCount": 1,
"heatingLoad": 4567,
"updatedAt": "2026-01-17T16:51:10Z",
"createdAt": "2026-01-17T16:51:10Z",
"name": "Entry",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
}
},
{
"registers": [1, 2],
"room": {
"coolingTotal": 1234,
"id": "449FE324-2ACF-4C12-83A3-EB86FD45A991",
"registerCount": 2,
"heatingLoad": 4567,
"updatedAt": "2026-01-17T16:51:02Z",
"createdAt": "2026-01-17T16:51:02Z",
"name": "Bed-1",
"projectID": "E796C96C-F527-4753-A00A-EBCF25630663"
}
}
]
},
"ductSize": {
"designCFM": {
"cooling": {
"_0": 1000
}
},
"roundSize": 13.539327773393,
"velocity": 935,
"finalSize": 14,
"flexSize": 16
}
}
],
"rooms": [
{
"ductSize": {
"velocity": 521,
"flexSize": 6,
"designCFM": {
"heating": {
"_0": 71.1469223845461
}
},
"roundSize": 4.95724506597333,
"finalSize": 5
},
"heatingCFM": 71.1469223845461,
"roomRegister": 1,
"heatingLoad": 2283.5,
"roomName": "Bed-1-1",
"roomID": "449FE324-2ACF-4C12-83A3-EB86FD45A991",
"coolingLoad": 512.11,
"coolingCFM": 53.1804861230822
},
{
"ductSize": {
"velocity": 521,
"flexSize": 6,
"designCFM": {
"heating": {
"_0": 71.1469223845461
}
},
"roundSize": 4.95724506597333,
"finalSize": 5
},
"heatingCFM": 71.1469223845461,
"roomRegister": 2,
"heatingLoad": 2283.5,
"roomName": "Bed-1-2",
"roomID": "449FE324-2ACF-4C12-83A3-EB86FD45A991",
"coolingLoad": 512.11,
"coolingCFM": 53.1804861230822
},
{
"ductSize": {
"velocity": 532,
"flexSize": 7,
"designCFM": {
"heating": {
"_0": 142.293844769092
}
},
"roundSize": 6.4510704920341,
"finalSize": 7
},
"heatingCFM": 142.293844769092,
"roomRegister": 1,
"heatingLoad": 4567,
"roomName": "Entry-1",
"roomID": "2420F9C7-0FCA-4E92-BAC9-8A054CB3201B",
"coolingLoad": 1024.22,
"coolingCFM": 106.360972246164
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 1,
"heatingLoad": 3292,
"roomName": "Kitchen-1",
"roomID": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 2,
"heatingLoad": 3292,
"roomName": "Kitchen-2",
"roomID": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 3,
"heatingLoad": 3292,
"roomName": "Kitchen-3",
"roomID": "FA2F107F-5DF0-4D69-B231-DE3C6EF64134",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 1,
"heatingLoad": 3292,
"roomName": "Master-1",
"roomID": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 2,
"heatingLoad": 3292,
"roomName": "Master-2",
"roomID": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
},
{
"ductSize": {
"velocity": 490,
"flexSize": 7,
"designCFM": {
"cooling": {
"_0": 131.213009251279
}
},
"roundSize": 6.25641154872314,
"finalSize": 7
},
"heatingCFM": 102.568718410303,
"roomRegister": 3,
"heatingLoad": 3292,
"roomName": "Master-3",
"roomID": "9A01CEE8-6A4B-4299-9353-58AFAD042903",
"coolingLoad": 1263.53666666667,
"coolingCFM": 131.213009251279
}
}
]

View File

@@ -1,6 +0,0 @@
@import "tailwindcss";
@source not "./tailwindcss";
@source not "./daisyui{,*}.mjs";
@plugin "./daisyui.mjs";

2825
output.css

File diff suppressed because it is too large Load Diff