Initial commit of RailMilesNG

This commit is contained in:
akp 2023-08-25 01:38:03 +01:00
commit c2faaa938f
No known key found for this signature in database
GPG key ID: CF8D58F3DEB20755
59 changed files with 6560 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
run/

40
go.mod Normal file
View file

@ -0,0 +1,40 @@
module github.com/codemicro/railmiles
go 1.20
require (
git.tdpain.net/pkg/cfger v0.1.0
github.com/PuerkitoBio/goquery v1.8.1
github.com/carlmjohnson/requests v0.23.4
github.com/gofiber/fiber/v2 v2.48.0
github.com/google/uuid v1.3.1
github.com/mattn/go-sqlite3 v1.14.17
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.29.1
github.com/uptrace/bun v1.1.14
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14
github.com/uptrace/bun/extra/bundebug v1.1.14
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63
)
require (
github.com/andybalholm/brotli v1.0.5 // indirect
github.com/andybalholm/cascadia v1.3.1 // indirect
github.com/fatih/color v1.15.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/klauspost/compress v1.16.3 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.48.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
golang.org/x/net v0.8.0 // indirect
golang.org/x/sys v0.11.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

114
go.sum Normal file
View file

@ -0,0 +1,114 @@
git.tdpain.net/pkg/cfger v0.1.0 h1:Yhs2DaFIdbcSrbyywhsoxrHPevDEEBEKbqJqrUa3eso=
git.tdpain.net/pkg/cfger v0.1.0/go.mod h1:Kq5/hsUnYSYM2BVGFtXMlYEDIsIYiZTz4MUIlqZeX0k=
github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM=
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c=
github.com/andybalholm/cascadia v1.3.1/go.mod h1:R4bJ1UQfqADjvDa4P6HZHLh/3OxWWEqc0Sk8XGwHqvA=
github.com/carlmjohnson/requests v0.23.4 h1:AxcvapfB9RPXLSyvAHk9YJoodQ43ZjzNHj6Ft3tQGdg=
github.com/carlmjohnson/requests v0.23.4/go.mod h1:Qzp6tW4DQyainPP+tGwiJTzwxvElTIKm0B191TgTtOA=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/fatih/color v1.15.0 h1:kOqh6YHBtK8aywxGerMG2Eq3H6Qgoqeo13Bk2Mv/nBs=
github.com/fatih/color v1.15.0/go.mod h1:0h5ZqXfHYED7Bhv2ZJamyIOUej9KtShiJESRwBDUSsw=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofiber/fiber/v2 v2.48.0 h1:cRVMCb9aUJDsyHxGFLwz/sGzDggdailZZyptU9F9cU0=
github.com/gofiber/fiber/v2 v2.48.0/go.mod h1:xqJgfqrc23FJuqGOW6DVgi3HyZEm2Mn9pRqUb2kHSX8=
github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4=
github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/klauspost/compress v1.16.3 h1:XuJt9zzcnaz6a16/OU53ZjWp/v7/42WcR5t2a0PcNQY=
github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.29.1 h1:cO+d60CHkknCbvzEWxP0S9K6KqyTjrCNUy1LdQLCGPc=
github.com/rs/zerolog v1.29.1/go.mod h1:Le6ESbR7hc+DP6Lt1THiV8CQSdkkNrd3R0XbEgp3ZBU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
github.com/uptrace/bun v1.1.14 h1:S5vvNnjEynJ0CvnrBOD7MIRW7q/WbtvFXrdfy0lddAM=
github.com/uptrace/bun v1.1.14/go.mod h1:RHk6DrIisO62dv10pUOJCz5MphXThuOTpVNYEYv7NI8=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14 h1:SlwXLxr+N1kEo8Q0cheRlnIZLZlWniEB1OI+jkiLgWE=
github.com/uptrace/bun/dialect/sqlitedialect v1.1.14/go.mod h1:9RTEj1l4bB9a4l1Mnc9y4COTwWlFYe1dh6fyxq1rR7A=
github.com/uptrace/bun/extra/bundebug v1.1.14 h1:9OCGfP9ZDlh41u6OLerWdhBtJAVGXHr0xtxO4xWi6t0=
github.com/uptrace/bun/extra/bundebug v1.1.14/go.mod h1:lto3guzS2v6mnQp1+akyE+ecBLOltevDDe324NXEYdw=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.48.0 h1:oJWvHb9BIZToTQS3MuQ2R3bJZiNSa2KiNdeI8A+79Tc=
github.com/valyala/fasthttp v1.48.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9znI5mJU=
github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ=
golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210916014120-12bc252f5db8/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,48 @@
package config
import (
"fmt"
"git.tdpain.net/pkg/cfger"
"github.com/codemicro/railmiles/railmiles/internal/util"
)
type Config struct {
Debug bool
HTTP struct {
Host string
Port int
}
RealTimeTrains struct {
Username string
Password string
}
Database struct {
DSN string
}
}
func (c *Config) HTTPAddress() string {
return fmt.Sprintf("%s:%d", c.HTTP.Host, c.HTTP.Port)
}
func Load() (*Config, error) {
cl := cfger.New()
const configFilename = "config.yml"
if err := cl.Load(configFilename); err != nil {
return nil, util.Wrap(err, "loading config from %s", configFilename)
}
conf := new(Config)
conf.Debug = cl.WithDefault("debug", false).AsBool()
conf.HTTP.Host = cl.WithDefault("http.host", "127.0.0.1").AsString()
conf.HTTP.Port = cl.WithDefault("http.port", 8080).AsInt()
conf.RealTimeTrains.Username = cl.Required("realtimetrains.username").AsString()
conf.RealTimeTrains.Password = cl.Required("realtimetrains.password").AsString()
conf.Database.DSN = cl.WithDefault("database.dsn", "railmiles.db").AsString()
return conf, nil
}

View file

@ -0,0 +1,52 @@
package core
import (
_ "embed"
"encoding/json"
"github.com/codemicro/railmiles/railmiles/internal/config"
"github.com/codemicro/railmiles/railmiles/internal/db"
)
type Core struct {
config *config.Config
db *db.DB
}
func New(conf *config.Config, database *db.DB) *Core {
return &Core{
config: conf,
db: database,
}
}
//go:embed stationData.json
var stationDataRaw []byte
type StationDetail struct {
Name string
Lat float32
Lon float32
}
var (
stationData map[string]*StationDetail
)
func init() {
_ = json.Unmarshal(stationDataRaw, &stationData)
}
func GetStationName(short string) string {
x := short
if ff, found := stationData[short]; found {
x = ff.Name
}
return x
}
func GetStationDetail(short string) *StationDetail {
if x, found := stationData[short]; found {
return x
}
return nil
}

View file

@ -0,0 +1,81 @@
package core
import (
"encoding/json"
"github.com/codemicro/railmiles/railmiles/internal/db"
"github.com/codemicro/railmiles/railmiles/internal/util"
)
func (c *Core) GenerateJourneyGeoJSON(journeys []*db.Journey) string {
var stations []string
{
for _, journey := range journeys {
stations = append(stations, journey.To.Shortcode, journey.From.Shortcode)
}
stations = util.Deduplicate(stations)
}
var res []any
journeyLoop:
for _, journey := range journeys {
feature := make(map[string]any)
feature["type"] = "LineString"
feature["properties"] = map[string]string{"id": journey.ID.String()}
routeStations := []string{journey.From.Shortcode}
routeStations = append(routeStations, util.Map(journey.Via, func(x *db.StationName) string {
return x.Shortcode
})...)
routeStations = append(routeStations, journey.To.Shortcode)
var route []string
for i := 0; i < len(routeStations)-1; i += 1 {
a := routeStations[i]
b := routeStations[i+1]
route = append(route, a)
points, err := c.GetCallingPoints(a, b)
if err == nil {
route = append(route, points...)
}
}
route = append(route, routeStations[len(routeStations)-1])
var coords [][]float32
{
last := len(route) - 1
for i, point := range route {
details := GetStationDetail(point)
if details == nil {
if i == 0 || i == last {
continue journeyLoop
}
continue
}
coords = append(coords, []float32{details.Lon, details.Lat})
}
}
feature["coordinates"] = coords
res = append(res, feature)
}
for _, station := range stations {
stationDetails := GetStationDetail(station)
if stationDetails == nil {
continue
}
res = append(res, map[string]any{
"type": "Feature",
"properties": map[string]any{"name": station + " " + GetStationName(station)},
"geometry": map[string]any{"type": "Point", "coordinates": []float32{stationDetails.Lon, stationDetails.Lat}},
})
}
o, _ := json.Marshal(res)
return string(o)
}

View file

@ -0,0 +1,37 @@
#!/usr/bin/env python3
import requests
import json
import sys
url = "https://overpass-api.de/api/interpreter"
query = """[out:json][timeout:25];
(node["ref:crs"];);
out body;"""
print("Querying Overpass", file=sys.stderr)
r = requests.post(url, data={"data": query})
r.raise_for_status()
rj = r.json()
out = {}
print("Processing results", file=sys.stderr)
for elem in rj.get("elements", []):
tags = elem.get("tags", {})
crs = tags.get("ref:crs")
if crs is None:
continue
out[crs] = {
"lat": elem.get("lat", 0),
"lon": elem.get("lon", 0),
"name": tags.get("name", ""),
}
json.dump(out, open("stationData.json", "w"))

View file

@ -0,0 +1,149 @@
package core
import (
"context"
"database/sql"
"fmt"
"github.com/codemicro/railmiles/railmiles/internal/db"
"github.com/codemicro/railmiles/railmiles/internal/util"
"github.com/google/uuid"
"github.com/pkg/errors"
)
type timeSince uint8
const (
AllTime timeSince = iota
LastMonth
YearToDate
)
func (ts timeSince) SQLDuration() (string, error) {
var dur string
switch ts {
case AllTime:
case LastMonth:
dur = "-1 month"
case YearToDate:
dur = "start of year"
default:
return "", fmt.Errorf("unknown timeSince %d", ts)
}
return dur, nil
}
type GetJourneysArgs struct {
Since timeSince
Offset int
Limit int
}
func (c *Core) GetJourneys(args *GetJourneysArgs) ([]*db.Journey, error) {
var journeys []*db.Journey
q := c.db.DB.NewSelect().
Model(&journeys).
OrderExpr(`"journey"."date" DESC`)
if args.Offset != 0 {
q = q.Offset(args.Offset)
}
if args.Limit != 0 {
q = q.Limit(args.Limit)
}
dur, err := args.Since.SQLDuration()
if err != nil {
return nil, util.Wrap(err, "getting journeys")
}
if dur != "" {
q = q.Where(`"journey"."date" > date('now', ?)`, dur)
}
if err := q.Scan(context.Background()); err != nil {
return nil, fmt.Errorf("querying past journeys: %w", err)
}
return journeys, nil
}
type JourneyStats struct {
Count int `json:"count"`
// RawCount is the count as in the number of rows, not the count as in the
// number of trips (ie. counts return journeys as one)
RawCount int `json:"rawCount"`
Miles float32 `json:"miles"`
}
func (c *Core) GetJourneyStats(since timeSince) (*JourneyStats, error) {
var (
rtns []bool
distQtys []float32
countQtys []int
)
q := c.db.DB.NewSelect().
Model((*db.Journey)(nil)).
ColumnExpr("return, sum(distance), count(*)").
Group("return")
dur, err := since.SQLDuration()
if err != nil {
return nil, util.Wrap(err, "getting journey stats")
}
if dur != "" {
q = q.Where(`"journey"."date" > date('now', ?)`, dur)
}
if err := q.Scan(context.Background(), &rtns, &distQtys, &countQtys); err != nil {
return nil, fmt.Errorf("querying total miles: %w", err)
}
js := new(JourneyStats)
for i := 0; i < len(rtns); i += 1 {
if rtns[i] { // if this is a return journey:
js.Count += countQtys[i] * 2
js.Miles += distQtys[i] * 2
} else {
js.Count += countQtys[i]
js.Miles += distQtys[i]
}
js.RawCount += countQtys[i]
}
return js, nil
}
func PopulateFullStationNames(journeys []*db.Journey) {
for _, journey := range journeys {
journey.From.Full = GetStationName(journey.From.Shortcode)
journey.To.Full = GetStationName(journey.To.Shortcode)
for _, via := range journey.Via {
via.Full = GetStationName(via.Shortcode)
}
}
}
func (c *Core) GetJourney(id uuid.UUID) (*db.Journey, error) {
j := new(db.Journey)
err := c.db.DB.NewSelect().Model(j).Where("id = ?", id).Scan(context.Background(), j)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
return nil, err
}
return j, nil
}
func (c *Core) DeleteJourney(id uuid.UUID) error {
_, err := c.db.DB.NewDelete().Model((*db.Journey)(nil)).Where("id = ?", id).Exec(context.Background())
return err
}
func (c *Core) InsertJourney(journey *db.Journey) error {
_, err := c.db.DB.NewInsert().Model(journey).Exec(context.Background())
return err
}

View file

@ -0,0 +1,173 @@
package core
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/carlmjohnson/requests"
"github.com/codemicro/railmiles/railmiles/internal/db"
"github.com/codemicro/railmiles/railmiles/internal/util"
"github.com/rs/zerolog/log"
"math"
"regexp"
"strconv"
"strings"
"time"
)
func (c *Core) GetRouteDistance(stations []string, services []string, date time.Time) (float32, error) {
year := strconv.Itoa(time.Now().Year())
month := strconv.Itoa(int(time.Now().Month()))
if len(month) == 1 {
month = "0" + month
}
day := strconv.Itoa(time.Now().Day())
if len(day) == 1 {
day = "0" + day
}
todayRunDate := date.Format("2006-01-02")
for i := 0; i < len(stations)-1; i += 1 {
if services[i] == "" {
var rttResp struct {
Services []struct {
ServiceUid string `json:"serviceUid"`
IsPassenger bool `json:"isPassenger"`
RunDate string `json:"runDate"`
LocationDetail struct {
DisplayAs string `json:"displayAs"`
} `json:"locationDetail"`
} `json:"services"`
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
err := requests.
URL("https://api.rtt.io").
Pathf("/api/v1/json/search/%s/to/%s/%s/%s/%s", stations[i], stations[i+1], year, month, day).
ToJSON(&rttResp).
BasicAuth(c.config.RealTimeTrains.Username, c.config.RealTimeTrains.Password).
Fetch(ctx)
cancel()
if err != nil {
return 0, fmt.Errorf("search for service %s->%s: %w", stations[i], stations[i+1], err)
}
uid := ""
for _, service := range rttResp.Services {
if service.RunDate == todayRunDate && // If this train started on a different date and runs through midnight
!strings.EqualFold(service.LocationDetail.DisplayAs, "CANCELLED_CALL") && // If this train was cancelled
service.IsPassenger {
uid = service.ServiceUid
break
}
}
if uid == "" {
return 0, errors.New("no route found")
}
services[i] = uid
}
}
var total float32
for i := 0; i < len(stations)-1; i += 1 {
dist, err := c.getSingleTrainDistance(services[i], stations[i], stations[i+1], date)
if err != nil {
return 0, util.Wrap(err, "scraping train")
}
total += dist
}
return total, nil
}
var shortcodeRegexp = regexp.MustCompile(`[A-Z]{3}`)
func (c *Core) getSingleTrainDistance(uid, departure, destination string, date time.Time) (float32, error) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
var htmlContent string
err := requests.
URL("https://www.realtimetrains.co.uk").
Pathf("/service/gb-nr:%s/%s/detailed", uid, date.Format("2006-01-02")).
ToString(&htmlContent).
Fetch(ctx)
if err != nil {
return 0, fmt.Errorf("fetch train with UID %s: %w", uid, err)
}
doc, err := goquery.NewDocumentFromReader(bytes.NewBufferString(htmlContent))
if err != nil {
return 0, fmt.Errorf("load RTT HTML: %w", err)
}
var waypoints [][3]string
doc.Find(".location.call,.location.pass").Each(func(i int, selection *goquery.Selection) {
shortcode := shortcodeRegexp.FindString(
selection.Find(".location a").Text(),
)
if shortcode == "" {
return
}
waypoints = append(waypoints, [3]string{
shortcode,
strings.TrimSpace(selection.Find("span.miles").Text()),
strings.TrimSpace(selection.Find("span.chains").Text()),
})
})
var distances []float32
for _, wp := range waypoints {
if strings.EqualFold(wp[0], departure) || strings.EqualFold(wp[0], destination) {
if wp[1] == "" || wp[2] == "" {
return 0, util.UserError(fmt.Errorf("no distance information provided for %s -> %s (%s) - manual distance required", departure, destination, uid))
}
miles, err := strconv.Atoi(wp[1])
if err != nil {
return 0, fmt.Errorf("parse miles: %w (%#v)", err, wp[1])
}
chains, err := strconv.Atoi(wp[2])
if err != nil {
return 0, fmt.Errorf("parse chains: %w (%#v)", err, wp[2])
}
distances = append(distances, float32(miles)+util.ChainsToMiles(chains))
}
}
if len(distances) != 2 {
return 0, fmt.Errorf("unexpected number of occurences of source/dest stations in RTT HTML (got %d, expected 2)", len(distances))
}
var route []string
{
var inbetweenTerminii bool
for _, wp := range waypoints {
if strings.EqualFold(wp[0], departure) {
inbetweenTerminii = true
} else if strings.EqualFold(wp[0], destination) {
if !inbetweenTerminii {
return 0, errors.New("unexpectedly formatted route: destination before departure")
}
break
} else if inbetweenTerminii {
route = append(route, wp[0])
}
}
}
err = c.InsertRoute(&db.Route{
From: departure,
To: destination,
Route: route,
})
if err != nil {
log.Warn().Err(err).Msg("failed to save route")
}
return float32(math.Abs(float64(distances[1] - distances[0]))), nil
}

View file

@ -0,0 +1,35 @@
package core
import (
"context"
"database/sql"
"errors"
"github.com/codemicro/railmiles/railmiles/internal/db"
)
func (c *Core) GetCallingPoints(from, to string) ([]string, error) {
route := new(db.Route)
err := c.db.DB.NewSelect().Model(route).Where(`"from" = ? and "to" = ?`, from, to).Scan(context.Background(), route)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
err := c.db.DB.NewSelect().Model(route).Where(`"from" = ? and "to" = ?`, to, from).Scan(context.Background(), route)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return []string{}, nil
}
return nil, err
}
// reverse route
for i, j := 0, len(route.Route)-1; i < j; i, j = i+1, j-1 {
route.Route[i], route.Route[j] = route.Route[j], route.Route[i]
}
}
}
return route.Route, err
}
func (c *Core) InsertRoute(route *db.Route) error {
_, err := c.db.DB.NewInsert().Ignore().Model(route).Exec(context.Background())
return err
}

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,26 @@
package db
import (
"context"
"github.com/codemicro/railmiles/railmiles/internal/util"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
func(ctx context.Context, db *bun.DB) error {
if _, err := db.NewCreateTable().Model((*Journey)(nil)).Exec(ctx); err != nil {
return util.Wrap(err, "creating journey table")
}
return nil
},
func(ctx context.Context, db *bun.DB) error {
if _, err := db.NewDropTable().Model((*Journey)(nil)).Exec(ctx); err != nil {
return util.Wrap(err, "dropping journey table")
}
return nil
},
)
}

View file

@ -0,0 +1,31 @@
package db
import (
"context"
"github.com/codemicro/railmiles/railmiles/internal/util"
"github.com/uptrace/bun"
)
func init() {
Migrations.MustRegister(
func(ctx context.Context, db *bun.DB) error {
_, err := db.NewRaw(`CREATE TABLE "railmiles_routes" (
"from" VARCHAR COLLATE NOCASE,
"to" VARCHAR COLLATE NOCASE,
"route" VARCHAR,
PRIMARY KEY ("from", "to")
)
`).Exec(ctx)
if err != nil {
return util.Wrap(err, "creating routes table")
}
return nil
},
func(ctx context.Context, db *bun.DB) error {
if _, err := db.NewDropTable().Model((*Route)(nil)).Exec(ctx); err != nil {
return util.Wrap(err, "dropping routes table")
}
return nil
},
)
}

View file

@ -0,0 +1,65 @@
package db
import (
"context"
"database/sql"
"github.com/codemicro/railmiles/railmiles/internal/config"
_ "github.com/mattn/go-sqlite3"
"github.com/uptrace/bun"
"github.com/uptrace/bun/dialect/sqlitedialect"
"github.com/uptrace/bun/extra/bundebug"
"github.com/uptrace/bun/migrate"
"golang.org/x/exp/slog"
"time"
)
var Migrations = migrate.NewMigrations()
type DB struct {
DB *bun.DB
}
func New(conf *config.Config) (*DB, error) {
dsn := conf.Database.DSN
slog.Info("connecting to database")
db, err := sql.Open("sqlite3", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // https://github.com/mattn/go-sqlite3/issues/274#issuecomment-191597862
b := bun.NewDB(db, sqlitedialect.New())
if conf.Debug {
b.AddQueryHook(bundebug.NewQueryHook(bundebug.WithVerbose(true)))
}
return &DB{b}, nil
}
func (db *DB) Migrate() error {
slog.Info("running database migrations")
mig := migrate.NewMigrator(db.DB, Migrations)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := mig.Init(ctx); err != nil {
return err
}
group, err := mig.Migrate(ctx)
if err != nil {
return err
}
if group.IsZero() {
slog.Info("no migrations applied (database up to date)")
} else {
slog.Info("migrations applied")
}
return nil
}

View file

@ -0,0 +1,65 @@
package db
import (
"bytes"
"database/sql/driver"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/uptrace/bun"
"time"
)
type Journey struct {
bun.BaseModel `bun:"table:railmiles_journeys" json:"-"`
ID uuid.UUID `bun:",pk,type:uuid" json:"id"`
From *StationName `json:"from"`
To *StationName `json:"to"`
Via []*StationName `bun:",nullzero" json:"via"`
Distance float32 `json:"distance"`
Date time.Time `json:"date"`
Return bool `json:"return"`
}
type Route struct {
bun.BaseModel `bun:"table:railmiles_routes"`
From string
To string
Route []string `bun:",nullzero"`
}
type StationName struct {
Shortcode string
Full string
}
func (sn *StationName) UnmarshalJSON(x []byte) error {
if bytes.Equal(x, []byte("null")) {
return nil
}
return json.Unmarshal(x, &sn.Shortcode)
}
func (sn *StationName) MarshalJSON() ([]byte, error) {
if sn.Full == "" {
return json.Marshal(sn.Shortcode)
} else {
return json.Marshal(map[string]string{"full": sn.Full, "shortcode": sn.Shortcode})
}
}
func (sn *StationName) Scan(src any) error {
if t, ok := src.(string); !ok {
return fmt.Errorf("(*StationName).Scan can only read strings (not %T)", src)
} else {
sn.Shortcode = t
}
return nil
}
func (sn *StationName) Value() (driver.Value, error) {
return sn.Shortcode, nil
}

View file

@ -0,0 +1,52 @@
package httpsrv
import (
"encoding/json"
"github.com/codemicro/railmiles/railmiles/internal/core"
"github.com/codemicro/railmiles/railmiles/internal/db"
"github.com/codemicro/railmiles/railmiles/internal/util"
"github.com/gofiber/fiber/v2"
)
func (hs *httpServer) dashboardInfo(ctx *fiber.Ctx) error {
var response = struct {
GeoJSON json.RawMessage `json:"geoJSON,omitempty"`
Stats struct {
LastMonth *core.JourneyStats `json:"lastMonth"`
YTD *core.JourneyStats `json:"ytd"`
AllTime *core.JourneyStats `json:"allTime"`
} `json:"stats"`
Journeys []*db.Journey `json:"journeys"`
}{}
journeys, err := hs.core.GetJourneys(&core.GetJourneysArgs{Since: core.LastMonth})
if err != nil {
return util.Wrap(err, "fetching journeys in the last month")
}
response.GeoJSON = []byte(hs.core.GenerateJourneyGeoJSON(journeys))
lastMonthStats, err := hs.core.GetJourneyStats(core.LastMonth)
if err != nil {
return util.Wrap(err, "fetching last month stats")
}
ytdStats, err := hs.core.GetJourneyStats(core.YearToDate)
if err != nil {
return util.Wrap(err, "fetching year-to-date stats")
}
allTimeStats, err := hs.core.GetJourneyStats(core.AllTime)
if err != nil {
return util.Wrap(err, "fetching all time stats")
}
response.Stats.LastMonth = lastMonthStats
response.Stats.YTD = ytdStats
response.Stats.AllTime = allTimeStats
core.PopulateFullStationNames(journeys)
response.Journeys = journeys
return ctx.JSON(response)
}

View file

@ -0,0 +1,48 @@
package httpsrv
import (
"github.com/codemicro/railmiles/railmiles/internal/config"
"github.com/codemicro/railmiles/railmiles/internal/core"
webAssets "github.com/codemicro/railmiles/web"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"net/http"
)
type httpServer struct {
config *config.Config
core *core.Core
}
func Run(conf *config.Config, c *core.Core) error {
srv := &httpServer{
config: conf,
core: c,
}
app := fiber.New()
if conf.Debug {
app.Use(cors.New())
}
srv.registerRoutes(app)
return app.Listen(conf.HTTPAddress())
}
func (hs *httpServer) registerRoutes(app *fiber.App) {
app.Get("/api/dashboard", hs.dashboardInfo)
app.Get("/api/journeys", hs.journeyListing)
app.Post("/api/journeys", hs.newJourney)
app.Get("/api/journeys/:id", hs.getJourney)
app.Delete("/api/journeys/:id", hs.deleteJourney)
app.Use(filesystem.New(filesystem.Config{
Root: http.FS(webAssets.Public),
PathPrefix: "public",
}))
}
type StockResponse struct {
Ok bool `json:"ok"`
Message string `json:"message,omitempty"`
}

View file

@ -0,0 +1,94 @@
package httpsrv
import (
"encoding/json"
"fmt"
"github.com/codemicro/railmiles/railmiles/internal/core"
"github.com/codemicro/railmiles/railmiles/internal/db"
"github.com/codemicro/railmiles/railmiles/internal/util"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"math"
"strconv"
)
func (hs *httpServer) journeyListing(ctx *fiber.Ctx) error {
const pageSize = 20
var pageNumber uint
{
pageNumberStr := ctx.Query("page", "0")
pageNumber64, _ := strconv.ParseUint(pageNumberStr, 10, 32)
pageNumber = uint(pageNumber64)
}
var response = struct {
NumPages int `json:"numPages"`
PageNumber uint `json:"pageNumber"`
Data []*db.Journey `json:"data"`
}{
PageNumber: pageNumber,
}
journeyStats, err := hs.core.GetJourneyStats(core.AllTime)
if err != nil {
return util.Wrap(err, "getting all journey stats")
}
fmt.Println(math.Ceil(float64(journeyStats.RawCount/pageSize)), journeyStats.RawCount, pageSize)
response.NumPages = int(math.Ceil(float64(journeyStats.RawCount/pageSize))) + 1
if !(int(pageNumber*pageSize) > journeyStats.RawCount) {
journeys, err := hs.core.GetJourneys(&core.GetJourneysArgs{Offset: int(pageSize * pageNumber), Limit: pageSize})
if err != nil {
return util.Wrap(err, "getting paginated journeys")
}
core.PopulateFullStationNames(journeys)
response.Data = journeys
}
return ctx.JSON(response)
}
func (hs *httpServer) getJourney(ctx *fiber.Ctx) error {
var response = struct {
GeoJSON json.RawMessage `json:"geoJSON"`
Data *db.Journey `json:"data"`
}{}
id, err := uuid.Parse(ctx.Params("id"))
if err != nil {
return fiber.ErrNotFound
}
journey, err := hs.core.GetJourney(id)
if err != nil {
return util.Wrap(err, "fetching journey %s", id.String())
}
if journey == nil {
return fiber.ErrNotFound
}
ja := []*db.Journey{journey}
core.PopulateFullStationNames(ja)
response.Data = journey
response.GeoJSON = []byte(hs.core.GenerateJourneyGeoJSON(ja))
return ctx.JSON(&response)
}
func (hs *httpServer) deleteJourney(ctx *fiber.Ctx) error {
id, err := uuid.Parse(ctx.Params("id"))
if err != nil {
return fiber.ErrNotFound
}
if err := hs.core.DeleteJourney(id); err != nil {
return util.Wrap(err, "deleting journey %s", id.String())
}
ctx.Status(204)
return nil
}

View file

@ -0,0 +1,128 @@
package httpsrv
import (
"encoding/json"
"fmt"
"github.com/codemicro/railmiles/railmiles/internal/db"
"github.com/codemicro/railmiles/railmiles/internal/util"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"regexp"
"strings"
"time"
)
var locationsRegexp = regexp.MustCompile(`(?:(?:[A-Z]){3}(?:, ?[A-Z][0-9]{5})?\n?)+`)
func (hs *httpServer) newJourney(ctx *fiber.Ctx) error {
var requestBody = struct {
Date time.Time `json:"date"`
Route string `json:"route"`
ManualDistance float32 `json:"manualDistance"`
IsReturn bool `json:"isReturn"`
}{}
var response = struct {
ID uuid.UUID `json:"id"`
}{}
if !strings.EqualFold(ctx.Get("Content-Type"), "application/json") {
ctx.Status(400)
return ctx.JSON(StockResponse{
Ok: false,
Message: "invalid Content-Type (requires application/json)",
})
}
if err := json.Unmarshal(ctx.Body(), &requestBody); err != nil {
ctx.Status(400)
return ctx.JSON(StockResponse{
Ok: false,
Message: "unable to parse request body",
})
}
if requestBody.Date.After(time.Now()) {
ctx.Status(400)
return ctx.JSON(StockResponse{
Ok: false,
Message: "Invalid date: occurs in the future",
})
}
requestBody.Date = requestBody.Date.UTC()
if !locationsRegexp.MatchString(requestBody.Route) {
ctx.Status(400)
return ctx.JSON(StockResponse{
Ok: false,
Message: "Invalid route format",
})
}
var (
needsServiceUID = time.Now().UTC().Truncate(24*time.Hour) != requestBody.Date.Truncate(24*time.Hour)
locations []string
services []string
)
{
lines := strings.Split(requestBody.Route, "\n")
for i, line := range lines {
p := strings.Split(line, ",")
if len(p) == 1 {
if needsServiceUID && i != len(lines)-1 && requestBody.ManualDistance == 0 {
ctx.Status(400)
return ctx.JSON(StockResponse{
Ok: false,
Message: "Service UIDs required as services were run on a different day to today",
})
}
services = append(services, "")
} else {
services = append(services, strings.TrimSpace(p[1]))
}
locations = append(locations, strings.TrimSpace(p[0]))
}
}
var dist float32
if requestBody.ManualDistance != 0 {
dist = requestBody.ManualDistance
} else {
var err error
dist, err = hs.core.GetRouteDistance(locations, services, requestBody.Date)
if err != nil {
ctx.Status(400)
return ctx.JSON(StockResponse{
Ok: false,
Message: "Unable to fetch distance: " + err.Error(),
})
}
}
var via []string
if len(locations) > 2 {
via = locations[1 : len(locations)-1]
}
j := &db.Journey{
ID: uuid.New(),
From: &db.StationName{Shortcode: locations[0]},
To: &db.StationName{Shortcode: locations[len(locations)-1]},
Via: util.Map(via, func(x string) *db.StationName {
return &db.StationName{Shortcode: x}
}),
Distance: dist,
Date: requestBody.Date,
Return: requestBody.IsReturn,
}
if err := hs.core.InsertJourney(j); err != nil {
return fmt.Errorf("inserting new journey: %w", err)
}
response.ID = j.ID
return ctx.JSON(&response)
}

View file

@ -0,0 +1,44 @@
package util
import (
"errors"
"fmt"
)
type UserError error
func Wrap(err error, msg string, args ...any) error {
e := err
for e != nil {
if _, ok := e.(UserError); ok {
return err
}
e = errors.Unwrap(err)
}
return fmt.Errorf(msg+": %w", append(args, err)...)
}
func Deduplicate[T comparable](x []T) []T {
var m = make(map[T]struct{})
var res []T
for _, v := range x {
if _, found := m[v]; !found {
res = append(res, v)
m[v] = struct{}{}
}
}
return res
}
func Map[T, Q comparable](x []T, y func(T) Q) []Q {
var res []Q
for _, z := range x {
res = append(res, y(z))
}
return res
}
func ChainsToMiles(chains int) float32 {
// 80 chains to a mile
return float32(chains) / 80
}

38
railmiles/main.go Normal file
View file

@ -0,0 +1,38 @@
package main
import (
"github.com/codemicro/railmiles/railmiles/internal/config"
"github.com/codemicro/railmiles/railmiles/internal/core"
"github.com/codemicro/railmiles/railmiles/internal/db"
"github.com/codemicro/railmiles/railmiles/internal/httpsrv"
"github.com/codemicro/railmiles/railmiles/internal/util"
"golang.org/x/exp/slog"
"os"
)
func main() {
if err := run(); err != nil {
slog.Error("unhandled error", "err", err)
os.Exit(1)
}
}
func run() error {
conf, err := config.Load()
if err != nil {
return util.Wrap(err, "loading configuration")
}
database, err := db.New(conf)
if err != nil {
return util.Wrap(err, "opening database")
}
if err := database.Migrate(); err != nil {
return util.Wrap(err, "migrating database")
}
c := core.New(conf, database)
return httpsrv.Run(conf, c)
}

4
web/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/node_modules/
/public/build/
.DS_Store

1679
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

26
web/package.json Normal file
View file

@ -0,0 +1,26 @@
{
"name": "svelte-app",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "rollup -c",
"dev": "rollup -c -w",
"start": "sirv public --no-clear"
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.0",
"@rollup/plugin-node-resolve": "^15.0.0",
"@rollup/plugin-terser": "^0.4.0",
"rollup": "^3.15.0",
"rollup-plugin-css-only": "^4.3.0",
"rollup-plugin-livereload": "^2.0.0",
"rollup-plugin-svelte": "^7.1.2",
"svelte": "^3.55.0"
},
"dependencies": {
"leaflet": "^1.9.4",
"sirv-cli": "^2.0.0",
"svelte-spa-router": "^3.3.0"
}
}

1981
web/public/assets/bootstrap-icons.css vendored Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

6
web/public/assets/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View file

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

View file

@ -0,0 +1,3 @@
@import "bootstrap.min.css";
@import "icons/bootstrap-icons.min.css";
@import "leaflet/leaflet.css";

BIN
web/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

19
web/public/index.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'>
<title>RailMiles</title>
<link rel='icon' type='image/png' href='/favicon.png'>
<link href="assets/styles.css" rel="stylesheet">
<script defer src="assets/bootstrap.bundle.min.js"></script>
<link rel='stylesheet' href='build/bundle.css'>
<script defer src='build/bundle.js'></script>
</head>
<body style="height: 100%;">
</body>
</html>

78
web/rollup.config.js Normal file
View file

@ -0,0 +1,78 @@
import { spawn } from 'child_process';
import svelte from 'rollup-plugin-svelte';
import commonjs from '@rollup/plugin-commonjs';
import terser from '@rollup/plugin-terser';
import resolve from '@rollup/plugin-node-resolve';
import livereload from 'rollup-plugin-livereload';
import css from 'rollup-plugin-css-only';
const production = !process.env.ROLLUP_WATCH;
function serve() {
let server;
function toExit() {
if (server) server.kill(0);
}
return {
writeBundle() {
if (server) return;
server = spawn('npm', ['run', 'start', '--', '--dev'], {
stdio: ['ignore', 'inherit', 'inherit'],
shell: true
});
process.on('SIGTERM', toExit);
process.on('exit', toExit);
}
};
}
export default {
input: 'src/main.js',
output: {
sourcemap: true,
format: 'iife',
name: 'app',
file: 'public/build/bundle.js'
},
plugins: [
svelte({
compilerOptions: {
// enable run-time checks when not in production
dev: !production
}
}),
// we'll extract any component CSS out into
// a separate file - better for performance
css({ output: 'bundle.css' }),
// If you have external dependencies installed from
// npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration -
// consult the documentation for details:
// https://github.com/rollup/plugins/tree/master/packages/commonjs
resolve({
browser: true,
dedupe: ['svelte'],
exportConditions: ['svelte']
}),
commonjs(),
// In dev mode, call `npm run start` once
// the bundle has been generated
!production && serve(),
// Watch the `public` directory and refresh the
// browser on changes when not in production
!production && livereload('public'),
// If we're building for production (npm run build
// instead of npm run dev), minify
production && terser()
],
watch: {
clearScreen: false
}
};

6
web/src/App.svelte Normal file
View file

@ -0,0 +1,6 @@
<script>
import Router from 'svelte-spa-router'
import routes from './routes'
</script>
<Router {routes} />

View file

@ -0,0 +1,78 @@
<script>
import {location} from "svelte-spa-router"
import sidebarOptions from "./sidebarOptions.js"
</script>
<div class="d-flex flex-column flex-shrink-0 p-3 text-white bg-dark sidebar d-none d-md-block">
<a href="/" class="d-flex align-items-center mb-3 mb-md-0 me-md-auto text-white text-decoration-none">
<i class="bi-train-front-fill pe-2" style="font-size: 32px;"></i>
<span class="fs-4">RailMiles</span>
</a>
<hr>
<ul class="nav nav-pills flex-column mb-auto">
{#each sidebarOptions as {name, icon, path}}
<li>
<a href="#{path}" class={path === $location ? "nav-link active" : "nav-link text-white"}>
<i class="bi-{icon}" style="font-size: 16px;"></i>
{name}
</a>
</li>
{/each}
</ul>
</div>
<div class="navbar bg-dark text-white d-block d-md-none">
<div class="container-fluid">
<a href="/" class="navbar-brand d-flex align-items-center text-white text-decoration-none">
<i class="bi-train-front-fill pe-2"></i>
<span class="fs-4">RailMiles</span>
</a>
<button class="navbar-toggler text-white" type="button" data-bs-toggle="collapse" data-bs-target="#navMobileDropdown">
<i style="font-size: 1.75rem" class="bi-list"></i>
</button>
</div>
</div>
<div class="collapse" id="navMobileDropdown" data-bs-theme="dark">
<ul class="nav nav-pills flex-column mb-auto bg-dark p-2">
{#each sidebarOptions as {name, icon, path}}
<li>
<a href="#{path}" class={path === $location ? "nav-link active" : "nav-link text-white"}>
<i class="bi-{icon}" style="font-size: 16px;"></i>
{name}
</a>
</li>
{/each}
</ul>
</div>
<div class="custom-container" style="">
<div class="pt-5 d-none d-sm-block"></div>
<div class="pt-4 d-block d-md-none"></div>
<slot></slot>
<div class="pt-5"></div>
</div>
<style>
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
box-shadow: 0 2px 5px 0 rgb(0 0 0 / 5%), 0 2px 10px 0 rgb(0 0 0 / 5%);
width: 280px;
}
.custom-container {
width: 80%;
margin-left: auto;
margin-right: auto;
padding-left: 280px;
}
@media (max-width: 575.98px) {
.custom-container {
width: 90%;
padding-left: unset;
}
}
</style>

View file

@ -0,0 +1,5 @@
<script>
export let message;
</script>
<div class="alert alert-danger" role="alert"><i class="bi-exclamation-octagon-fill"></i> {message}</div>

View file

@ -0,0 +1,64 @@
<script>
import {onMount} from "svelte";
import L from "leaflet";
export let geoJSON
let map
let geoJSONLayer
const updateGeoJSON = (obj) => {
if (map) {
geoJSONLayer.removeFrom(map)
}
geoJSONLayer = L.geoJSON(obj, { onEachFeature: (feature, layer) => {
if (feature.properties && feature.properties.name) {
layer.bindPopup(feature.properties.name);
}
}
})
if (map) {
geoJSONLayer.addTo(map)
try {
map.fitBounds(geoJSONLayer.getBounds()) // sometimes this errors, sometimes it doesn't
} catch {}
}
}
$: updateGeoJSON(geoJSON)
onMount(() => {
map = L.map("journey-map").setView([55.093, -2.894], 5);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
let ormOverlay = L.tileLayer('http://{s}.tiles.openrailwaymap.org/standard/{z}/{x}/{y}.png', {
attribution: '<a href="https://www.openstreetmap.org/copyright">© OpenStreetMap contributors</a>, Style: <a href="http://creativecommons.org/licenses/by-sa/2.0/">CC-BY-SA 2.0</a> <a href="http://www.openrailwaymap.org/">OpenRailwayMap</a> and OpenStreetMap',
minZoom: 2,
maxZoom: 19,
tileSize: 256,
className: "tile-orm",
});
L.control.layers({}, {"OpenRailwayMap": ormOverlay}).addTo(map);
updateGeoJSON(geoJSON)
})
</script>
<div id="journey-map" class="mt-4"></div>
<style>
#journey-map {
height: 450px;
}
:global(#journey-map .tile-orm .leaflet-tile) {
filter: grayscale(1);
}
</style>

View file

@ -0,0 +1,57 @@
<script>
import {formatDate, roundFloat} from "../util.js";
export let journeys = [];
export let showMore = false;
</script>
<table class="table table-sm table-hover">
<thead>
<tr>
<th scope="col">Date</th>
<th scope="col">Route</th>
<!-- <th scope="col">To</th>-->
<th scope="col"></th>
<th scope="col">Distance</th>
<th scope="col">Return</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{#each journeys as journey (journey.id)}
<tr>
<td>{formatDate(journey.date)}</td>
<td>{journey.from.full} to {journey.to.full}</td>
<td>
{#if journey.via}
via
{#each journey.via as station, i}
{#if i !== 0}, {/if}<abbr title="{station.full}">{station.shortcode}</abbr>
{/each}
{/if}
</td>
<td>{roundFloat(journey.distance, 1)} miles</td>
<td>
{#if journey.return }<i class="bi-check-lg"></i>{/if}
</td>
<td><a href="#/journeys/{journey.id}"><i class="bi-three-dots"></i></a></td>
</tr>
{:else}
<tr>
<td colspan="6" class="text-center bg-warning-subtle text-warning-emphasis">Nothing to display!</td>
</tr>
{/each}
{#if showMore}
<tr>
<td colspan="6"><a href="#/journeys">See more...</a></td>
</tr>
{/if}
</tbody>
</table>
<style>
table {
overflow: scroll;
}
</style>

View file

@ -0,0 +1,42 @@
<script>
export let transparent = false
export let text = undefined;
</script>
<div class="panel" class:transparent>
<div class="d-flex gap-4 flex-column justify-content-center align-items-center">
<div class="spinner-border" style="width: 4rem; height: 4rem;" role="status">
<span class="visually-hidden">Loading...</span>
</div>
{#if text}
<p>{text}</p>
{/if}
</div>
</div>
<style>
div.panel {
position: absolute;
top: 0;
left: 280px;
width: calc(100% - 280px);
height: 100%;
background-color: #ffffff;
z-index: 1500;
}
@media (max-width: 575.98px) {
div.panel {
left: 0;
width: 100%;
}
}
div.panel.transparent {
background-color: rgba(255, 255, 255, 0.5);
}
div.panel > div {
height: 100%;
}
</style>

View file

@ -0,0 +1,5 @@
<script>
export let message;
</script>
<div class="alert alert-success" role="alert"><i class="bi-check-circle-fill"></i> {message}</div>

View file

@ -0,0 +1,17 @@
export default [
{
name: "Dashboard",
icon: "speedometer2",
path: "/",
},
{
name: "Journey listing",
icon: "table",
path: "/journeys",
},
{
name: "Log new journey",
icon: "plus-lg",
path: "/new"
}
]

7
web/src/main.js Normal file
View file

@ -0,0 +1,7 @@
import App from './App.svelte';
const app = new App({
target: document.body,
});
export default app;

13
web/src/routes.js Normal file
View file

@ -0,0 +1,13 @@
import Home from './routes/Home.svelte'
import JourneyListing from './routes/JourneyListing.svelte'
import NewJourney from "./routes/NewJourney.svelte";
import NotFound from './routes/NotFound.svelte'
import JourneyDetail from "./routes/JourneyDetail.svelte";
export default {
'/': Home,
'/journeys': JourneyListing,
'/journeys/:id': JourneyDetail,
'/new': NewJourney,
'*': NotFound,
}

108
web/src/routes/Home.svelte Normal file
View file

@ -0,0 +1,108 @@
<script>
import BaseLayout from "../components/BaseLayout.svelte";
import L from "leaflet";
import { onMount } from "svelte";
import Loading from "../components/Loading.svelte";
import {makeURL, roundFloat} from "../util.js";
import JourneyTable from "../components/JourneyTable.svelte";
import JourneyMap from "../components/JourneyMap.svelte";
let map;
let stats = {
lastMonth: {count: 0, miles: 0},
ytd: {count: 0, miles: 0},
allTime: {count: 0, miles: 0},
};
let journeys;
let journeyGeoData;
let ready = false;
onMount(async () => {
let response;
try {
response = await fetch(makeURL("/api/dashboard"));
} catch (e) {
alert(e.toString())
return
}
const responseJSON = await response.json();
stats = responseJSON.stats;
journeys = responseJSON.journeys;
journeyGeoData = responseJSON.geoJSON
ready = true;
})
</script>
<BaseLayout>
{#if !ready}
<Loading />
{/if}
<h1 class="pb-4"><i class="bi-speedometer2"></i> Dashboard</h1>
<div class="row gap-2 g-2">
<div class="col-sm card text-bg-primary">
<div class="card-header">Last Month</div>
<div class="card-body">
<div class="d-flex text-center justify-content-center">
<div>
<span class="fs-2">{roundFloat(stats.lastMonth.miles, 1)}</span>
<span class="fs-5">miles</span>
</div>
<div>
<span class="fs-2">{stats.lastMonth.count}</span>
<span class="fs-5">journeys</span>
</div>
</div>
</div>
</div>
<div class="col-sm card text-bg-light">
<div class="card-header">Year-to-Date</div>
<div class="card-body">
<div class="d-flex text-center justify-content-center">
<div>
<span class="fs-2">{roundFloat(stats.ytd.miles, 1)}</span>
<span class="fs-5">miles</span>
</div>
<div>
<span class="fs-2">{stats.ytd.count}</span>
<span class="fs-5">journeys</span>
</div>
</div>
</div>
</div>
<div class="col-sm card text-bg-light">
<div class="card-header">All Time</div>
<div class="card-body">
<div class="d-flex text-center justify-content-center">
<div>
<span class="fs-2">{roundFloat(stats.allTime.miles, 1)}</span>
<span class="fs-5">miles</span>
</div>
<div>
<span class="fs-2">{stats.allTime.count}</span>
<span class="fs-5">journeys</span>
</div>
</div>
</div>
</div>
</div>
<h3 class="pt-4">Recent journeys</h3>
<JourneyMap geoJSON={journeyGeoData} />
<div class="pt-4"></div>
<JourneyTable showMore=true journeys={journeys} />
</BaseLayout>
<style>
.d-flex {
gap: 1em;
}
</style>

View file

@ -0,0 +1,114 @@
<script>
import BaseLayout from "../components/BaseLayout.svelte";
import {onMount} from "svelte";
import {formatDate, makeURL, roundFloat} from "../util.js";
import Loading from "../components/Loading.svelte";
import JourneyMap from "../components/JourneyMap.svelte";
import {push} from "svelte-spa-router";
let ready = false
let transparentLoading = false
export let params = {
id: undefined,
}
let journey;
let geoJSON;
onMount(async () => {
let response;
try {
response = await fetch(makeURL("/api/journeys/" + params.id));
} catch (e) {
alert(e.toString())
return
}
if (!response.ok) {
await push("/notfound")
}
const responseJSON = await response.json()
journey = responseJSON.data
geoJSON = responseJSON.geoJSON
ready = true
})
const deleteSelf = async () => {
if (!confirm("Are you sure you want to permanently delete this journey?")) {
return
}
transparentLoading = true
ready = false
let response;
try {
response = await fetch(makeURL("/api/journeys/" + params.id), {method: "DELETE"});
} catch (e) {
alert(e.toString())
return
}
if (!response.ok) {
alert(response.statusText)
return
}
alert("Success!")
await push("/journeys")
}
</script>
<BaseLayout>
{#if !ready}
<Loading transparent={transparentLoading}/>
{/if}
{#if journey}
<h1><i class="bi-ticket-detailed"></i> {journey.from.full} to {journey.to.full}</h1>
<JourneyMap geoJSON={geoJSON}/>
<table class="table mt-4 mb-4">
<tbody>
<tr>
<th scope="row">From</th>
<td>{journey.from.full} ({journey.from.shortcode})</td>
</tr>
<tr>
<th scope="row">To</th>
<td>{journey.to.full} ({journey.to.shortcode})</td>
</tr>
<tr>
<th scope="row">Via</th>
<td>
{#if journey.via}
{#each journey.via as station, i}
{#if i !== 0},{/if}{station.full} ({station.shortcode})
{/each}
{:else}
<span class="text-secondary"><i>n/a</i></span>
{/if}
</td>
</tr>
<tr>
<th scope="row">Date</th>
<td>{formatDate(journey.date)}</td>
</tr>
<tr>
<th scope="row">Distance</th>
<td>{roundFloat(journey.distance, 2)} miles</td>
</tr>
<tr>
<th scope="row">Return</th>
<td>{journey.return ? "Yes" : "No"}</td>
</tr>
</tbody>
</table>
<button class="btn btn-danger mb-4" on:click={deleteSelf}>Delete this journey</button>
<p class="text-secondary">Journey ID: <code>{journey.id}</code></p>
{/if}
</BaseLayout>

View file

@ -0,0 +1,62 @@
<script>
import BaseLayout from "../components/BaseLayout.svelte"
import JourneyTable from "../components/JourneyTable.svelte"
import {onMount} from "svelte"
import Loading from "../components/Loading.svelte"
import {makeURL} from "../util.js"
let journeys = []
let totalNumPages
let currentPage = 0
let ready = false
let transparentLoading = false
const getPage = async (n) => {
let response;
try {
response = await fetch(makeURL("/api/journeys?page=" + n));
} catch (e) {
alert(e.toString())
return
}
return await response.json()
}
onMount(async () => {
const resp = await getPage(currentPage)
totalNumPages = resp.numPages
journeys = resp.data
ready = true
transparentLoading = true
})
$: {
ready = false
getPage(currentPage).then((x) => {
journeys = x.data
ready = true
})
}
</script>
<BaseLayout>
{#if !ready}
<Loading transparent={transparentLoading}/>
{/if}
<h1><i class="bi-table"></i> Journey listing</h1>
<div class="pt-4"></div>
<nav class="d-flex justify-content-center">
<ul class="pagination">
<li class={currentPage === 0 ? "page-item disabled" : "page-item"}><a class="page-link" on:click={() => {currentPage--}}><i class="bi-chevron-left"></i></a></li>
{#each {length: totalNumPages} as _, i}
<li class={currentPage === i ? "page-item active" : "page-item"}><a class="page-link" on:click={() => {currentPage=i}}>{i+1}</a></li>
{/each}
<li class={currentPage+1 === totalNumPages ? "page-item disabled" : "page-item"}><a class="page-link" on:click={() => {currentPage++}}><i class="bi-chevron-right"></i></a></li>
</ul>
</nav>
<JourneyTable journeys={journeys}/>
</BaseLayout>

View file

@ -0,0 +1,140 @@
<script>
import BaseLayout from "../components/BaseLayout.svelte"
import {leftPad, makeURL} from "../util.js";
import Loading from "../components/Loading.svelte";
import {push} from "svelte-spa-router";
import ErrorAlert from "../components/ErrorAlert.svelte";
let problem
let loading
let inputs = {
rawDate: undefined,
date: undefined,
route: undefined,
manualDistance: undefined,
isReturn: false,
}
$: inputs.manualDistance = parseFloat(inputs.manualDistance)
$: {
inputs.date = new Date(Date.parse(inputs.rawDate))
console.log(inputs.rawDate)
}
const setDateToday = () => {
const today = new Date(Date.now());
inputs.rawDate = `${today.getFullYear()}-${leftPad(today.getMonth()+1, "0", 2)}-${leftPad(today.getDate(), "0", 2)}`
console.log("set to", inputs.rawDate)
}
const doFormSubmit = async (event) => {
event.preventDefault()
if (!inputs.rawDate) {
problem = "Please set the date of travel"
return
}
if (!inputs.route) {
problem = "Please provide a route"
return
}
loading = true
let response;
try {
response = await fetch(
makeURL("/api/journeys"),
{
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(inputs),
},
)
} catch (e) {
alert(e.toString())
loading = false
return
}
const responseJSON = await response.json()
loading = false
if (response.status === 400) {
problem = responseJSON.message
return
}
await push(`/journeys/${responseJSON.id}`)
}
</script>
<BaseLayout>
{#if loading}
<Loading transparent={true}/>
{/if}
<h1><i class="bi-plus-lg"></i> Log new journey</h1>
<div class="pt-4"></div>
{#if problem}
<ErrorAlert message={problem} />
<div class="pt-4"></div>
{/if}
<form on:submit={doFormSubmit}>
<div class="row pb-2">
<div class="col-sm">
<label for="inputTravelDate" class="form-label">Date of travel <a class="link-primary"
on:click={setDateToday}>(today)</a></label>
</div>
<div class="col-sm-8">
<input type="date" id="inputTravelDate" class="form-control" bind:value={inputs.rawDate}>
</div>
</div>
<div class="row pb-2">
<div class="col-sm">
<label for="inputRoute" class="form-label">Route</label>
<div class="form-text">Locations should be entered with the short code (eg: <code>SLY</code>) and
optionally the service UID (eg: <code>SLY, C16977</code>). Seperate locations with a newline.
</div>
</div>
<div class="col-sm-8">
<textarea type="date" id="inputRoute" rows="7" class="form-control"
bind:value={inputs.route}></textarea>
</div>
</div>
<div class="row pb-2">
<div class="col-sm">
<label for="inputManualDistance" class="form-label">Manual distance</label>
<div class="form-text">Leave blank to auto-detect. Enter values in miles.</div>
</div>
<div class="col-sm-8">
<input type="number" step="any" id="inputManualDistance" class="form-control" placeholder="Auto-detect"
bind:value={inputs.manualDistance}>
</div>
</div>
<div class="row pb-2">
<div class="col-sm">
<label for="inputReturnJourney" class="form-label">Was this a return journey?</label>
</div>
<div class="col-sm-8">
<input type="checkbox" id="inputReturnJourney" class="form-check-input" bind:checked={inputs.isReturn}>
</div>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>
</BaseLayout>
<style>
a.link-primary {
cursor: pointer;
}
</style>

View file

@ -0,0 +1,8 @@
<script>
import BaseLayout from "../components/BaseLayout.svelte";
</script>
<BaseLayout>
<h1>Page not found</h1>
<p>Click <a href="#/">here</a> to go back to the dashboard or use the sidebar for more options.</p>
</BaseLayout>

35
web/src/util.js Normal file
View file

@ -0,0 +1,35 @@
export const roundFloat = (x, decimalPlaces) => {
const scale = Math.pow(10, decimalPlaces)
x *= scale
x = Math.round(x)
x /= scale
return x
}
const baseURL = "1"
export const makeURL = (path) => {
if (baseURL.endsWith("/") && path.startsWith("/")) {
return baseURL + path.substring(1)
}
return baseURL + path
}
export const leftPad = (str, char, len) => {
str = str.toString()
if (str.length >= len) {
return str
}
while (str.length < len) {
str = char + str
}
return str
}
const dateFormat = {year: 'numeric', month: 'short', day: 'numeric'};
export const formatDate = (date) => {
return new Date(Date.parse(date)).toLocaleDateString(undefined, dateFormat)
}

6
web/web.go Normal file
View file

@ -0,0 +1,6 @@
package webAssets
import "embed"
//go:embed public/*
var Public embed.FS