Compare commits

...

285 commits
navbar ... main

Author SHA1 Message Date
Edward Betts 20e8515bb7 Sometimes the diff includes Python types 2024-09-09 19:16:21 +01:00
Edward Betts 82b2f69005 Fix text wrapping on gaps page 2024-09-02 09:00:37 +01:00
Edward Betts d1d7079056 Add hashtag as a field for conference 2024-08-27 12:49:57 +01:00
Edward Betts cf4508719a Switch tech conference emojii from 🎤 to 🖥️. 2024-08-17 14:40:04 +01:00
Edward Betts 259642ff52 Show current trip on home page 2024-08-11 12:51:06 +02:00
Edward Betts 6e9604e4c1 Use as_date() from utils 2024-08-11 12:51:06 +02:00
Edward Betts cf298f261f Bug fix 2024-08-07 06:26:42 +01:00
Edward Betts 23aa70bb84 Add option to filter launches by orbit 2024-08-05 14:03:37 +02:00
Edward Betts 8032cd2ed4 Increase rocket lanuch limit from 200 to 500 2024-08-05 14:02:45 +02:00
Edward Betts bd129aea5c Bug fix 2024-08-04 16:17:52 +01:00
Edward Betts b5188771be Ignore types in geopy.distance 2024-08-04 09:23:34 +02:00
Edward Betts b9adb3d15e Move timedelta_display() function to agenda.utils 2024-08-04 09:23:13 +02:00
Edward Betts 15c5053e44 Show page render time on events list 2024-08-04 12:01:54 +08:00
Edward Betts 11bc0419b3 Reorder accommodation list 2024-08-04 11:45:13 +08:00
Edward Betts 7169d1ba27 Improve support for adding a new currency 2024-08-04 11:44:44 +08:00
Edward Betts fb65b4d6fb Split code out into rocket_launch_events() 2024-08-03 14:58:32 +08:00
Edward Betts 7cdb6903fc Make mypy happier 2024-08-03 14:51:30 +08:00
Edward Betts f423fcdcbe Split up train and flight loading
Reduce complexity of train and flight loading functions by splitting
code out into separate functions.
2024-08-03 14:49:21 +08:00
Edward Betts a130a85a48 Add attendees to Conference dataclass 2024-07-27 19:39:22 +09:00
Edward Betts 9f54c3ac03 rename n_somerset_waste_collection_events function 2024-07-18 14:24:44 +09:00
Edward Betts 5b9a481bb2 move time_function to utils 2024-07-18 14:24:14 +09:00
Edward Betts f63b4d6b08 tell mypy to ignore the lack of types for ephem module 2024-07-18 14:22:55 +09:00
Edward Betts ace517c482 Reorder functions 2024-07-15 03:17:29 +08:00
Edward Betts ef695af7af Show year on birthday page 2024-07-15 02:23:13 +08:00
Edward Betts 28ad4950fd Improve types 2024-07-14 23:12:17 +08:00
Edward Betts 5c4eac60ee Adjust bristol waste function names 2024-07-14 23:10:00 +08:00
Edward Betts 0f3f596cb3 Split waste_schedule.py in two 2024-07-14 22:19:13 +08:00
Edward Betts 17eca6a95a Travel stats to show number of conference stats
Closes: #167
2024-07-10 19:28:56 +01:00
Edward Betts efae1b9b14 Hide GB from countries visited on trip stats 2024-07-10 19:28:07 +01:00
Edward Betts b38ec99628 Show changes in launch JSON 2024-07-09 08:22:48 +01:00
Edward Betts d1898686e9 Catch and ignore space launch API errors 2024-07-09 08:22:33 +01:00
Edward Betts 0c36591e2f Bug fix 2024-07-08 12:01:48 +01:00
Edward Betts 0683c98e6f Refactor 2024-07-08 11:58:12 +01:00
Edward Betts 07cf7dee3c Watch for new upcoming geomob events and announce 2024-07-08 11:49:50 +01:00
Edward Betts ff51bb9ff9 Move send_mail() to own module 2024-07-08 10:44:39 +01:00
Edward Betts 19a9015dba Fix typo 2024-07-07 19:20:54 +01:00
Edward Betts 5e70cbd633 Remove unused import 2024-07-07 13:30:41 +01:00
Edward Betts 8e34ceb458 Refactor 2024-07-07 13:30:27 +01:00
Edward Betts 089f569fd3 Remove unused URL 2024-07-07 13:29:47 +01:00
Edward Betts 4ef47374c2 Refactor bristol waste schedule code 2024-07-07 13:27:00 +01:00
Edward Betts e677082560 Add tests for utility functions 2024-07-07 12:02:21 +01:00
Edward Betts 128d7ac282 Add support for plural/singular time periods 2024-07-07 12:01:56 +01:00
Edward Betts e38e357f63 No need for DateOrDateTime type alias 2024-07-07 12:01:21 +01:00
Edward Betts e7ae7123f6 Rewrite date utils using match/case 2024-07-07 11:50:59 +01:00
Edward Betts a873060949 Move functions out of web_view.py 2024-07-07 11:32:03 +01:00
Edward Betts e66945a825 Refactor validate_yaml.py 2024-07-03 09:54:00 +03:00
Edward Betts e814e1b135 Adjust airline YAML format to allow more fields 2024-07-02 14:46:51 +03:00
Edward Betts 1e39e75117 Merge branch 'main' of https://git.4angle.com/edward/agenda 2024-07-02 07:15:23 +01:00
Edward Betts 52810ca676 Fix rocket name 2024-07-02 07:15:19 +01:00
Edward Betts 4e328d401e No wrap for date in weekend list 2024-07-01 22:29:13 +03:00
Edward Betts b65d79cb63 Add filters for space launches 2024-07-01 22:29:13 +03:00
Edward Betts c41bcc3304 Catch errors retrieving FX rates and return cached version 2024-07-01 22:29:13 +03:00
Edward Betts 01b42845c3 Move some functions into a utils module 2024-07-01 22:29:13 +03:00
Edward Betts 0e49d18721 Correct spelling mistake 2024-07-01 22:29:13 +03:00
Edward Betts b8ed1d5d65 Handle missing space launch 2024-06-26 08:48:58 +01:00
Edward Betts f4557d14e8 Show flight prices on individual trip pages
Closes: #153
2024-06-20 12:40:26 +01:00
Edward Betts 672948ed4d Hide GB from country list unless country
Closes: #162
2024-06-19 22:32:08 +01:00
Edward Betts 2e87d0f120 Consider railway stations for trip countries 2024-06-19 22:27:56 +01:00
Edward Betts b28d5241c6 Add missing calendar template 2024-06-19 22:16:22 +01:00
Edward Betts d91eab02ad Split recent events and calendar into separate pages.
Closes: #140
2024-06-19 22:15:32 +01:00
Edward Betts fcf935271c Market display filter
Closes: #158

Closes: #150
2024-06-18 06:51:45 +01:00
Edward Betts 895bf7c972 Include airport countries in trip country list 2024-06-17 16:29:19 +01:00
Edward Betts 0079f46a80 Improve display of countries on stats page 2024-06-17 16:28:45 +01:00
Edward Betts 40578196bc Show number of flights and trains on travel stats page
Closes: #161
2024-06-17 16:28:14 +01:00
Edward Betts d5bf004912 Add countries visited to trip stats page
Closes: #160
2024-06-17 16:10:07 +01:00
Edward Betts 1e14a99419 Travel stats: distance and price by year and travel type
Closes: #155
2024-06-16 11:31:23 +01:00
Edward Betts 12a2b739e8 Highlight followed space launches
Closes: #159
2024-06-15 23:02:09 +01:00
Edward Betts 31cd7e8b97 Include slug in launch summary 2024-06-15 23:01:31 +01:00
Edward Betts e400360697 Send alert emails for particular rocket launches
Closes: #156
2024-06-15 21:35:18 +01:00
Edward Betts ce07ae65b1 Switch to plurals for navbar labels 2024-06-15 20:57:59 +01:00
Edward Betts 882fa52ae5 Move past conferences to separate page
Closes: #157
2024-06-15 20:56:37 +01:00
Edward Betts 964bbb1162 Split market hiding into function with config flag 2024-06-15 20:35:47 +01:00
Edward Betts 6c76610cdb Add type hints 2024-06-15 20:35:25 +01:00
Edward Betts 4c651198f3 Add birthday list page 2024-06-03 19:30:14 +01:00
Edward Betts 733608bc2f Fix spelling 2024-06-02 19:05:04 +01:00
Edward Betts 5de5e22883 Fix docstrings 2024-06-02 19:04:37 +01:00
Edward Betts ade6989300 Show links to flight data web sites 2024-06-02 19:01:18 +01:00
Edward Betts 537a84ff67 Move flag display to trip list template 2024-05-27 11:10:53 +02:00
Edward Betts 75242c2952 Show end times for travel 2024-05-27 10:57:46 +02:00
Edward Betts 38f2e10c6d Show distances for all past and future trips. 2024-05-27 10:16:03 +02:00
Edward Betts cd8dfb74a4 Use defaultdict, not Counter for travel distances 2024-05-27 10:13:24 +02:00
Edward Betts f8c523c674 Add format_distance macro 2024-05-27 10:12:58 +02:00
Edward Betts 093000bbc3 Show more detail for flights 2024-05-20 20:56:14 +02:00
Edward Betts 8181dfbe3b Remove unused code from trip list HTML 2024-05-20 18:33:53 +02:00
Edward Betts a96aefe22b Improve trip list template 2024-05-20 18:32:49 +02:00
Edward Betts 5758d3f1d0 Add more flags on trip list 2024-05-19 13:33:04 +02:00
Edward Betts 1948ab8ff5 Rewrite TripElement.get_emoji() to use dict lookup 2024-05-19 08:59:27 +02:00
Edward Betts 277e991869 Validate airport and station YAML 2024-05-18 20:37:10 +02:00
Edward Betts d4dda44768 Add more country flags on the trip list. 2024-05-18 20:29:29 +02:00
Edward Betts 448d59514b Use ndash instead of mdash 2024-05-18 18:02:35 +02:00
Edward Betts 34d7655ace Remove duplicate emoji 2024-05-18 18:02:14 +02:00
Edward Betts 7e8d156126 Show check-in and check-out times 2024-05-18 16:53:38 +02:00
Edward Betts 455528125c Improvements to trip list pages 2024-05-18 16:44:18 +02:00
Edward Betts 7d376b38f3 Use heading in trip list page title 2024-05-18 16:43:06 +02:00
Edward Betts ab74ebab34 Remove unused imports 2024-05-18 14:59:09 +02:00
Edward Betts 85ebaf7c84 Show indivudal train legs 2024-05-18 14:23:00 +02:00
Edward Betts 5b2d248955 Use proper arrow in ferry title 2024-05-18 14:22:35 +02:00
Edward Betts f1a472a944 Better airport labels 2024-05-18 14:22:08 +02:00
Edward Betts d5a92c9a8e Trip list URL to redirect 2024-05-18 12:46:32 +02:00
Edward Betts a253f720dd Switch to singulr for navbar 2024-05-18 12:44:23 +02:00
Edward Betts 2e1cf0ce84 Show current trip on future trip map 2024-05-18 12:42:41 +02:00
Edward Betts c9fcf1d5e7 Include current trip in future list 2024-05-18 12:14:34 +02:00
Edward Betts afb96bc855 Bug fix validate_yaml.py 2024-05-18 12:04:28 +02:00
Edward Betts cd16b857a0 Split trip list into future and past pages
Redo page layout and trip display. Map is now shown on the right.
2024-05-18 12:02:21 +02:00
Edward Betts 3ec7f5c18a Improve duration display 2024-05-16 16:58:07 +02:00
Edward Betts dd59c809e1 Split out busy events code 2024-05-16 16:23:46 +02:00
Edward Betts 7bb6110f45 Split out code for reading events from YAML 2024-05-16 15:21:57 +01:00
Edward Betts 18d8fa6b7c Split out rio_carnival_events function 2024-05-16 15:18:56 +01:00
Edward Betts 78c90b0164 Bug fix ESA detection 2024-05-10 10:16:39 +01:00
Edward Betts 096e0a371e Adjust overlap of markers 2024-05-06 12:48:06 +03:00
Edward Betts dd82470835 Arrange map markers to overlap less 2024-05-06 11:36:00 +03:00
Edward Betts c65e60a1f1 Include ferry distances 2024-05-05 14:57:13 +03:00
Edward Betts ca7c449410 Add eslintrc.js 2024-05-05 14:56:36 +03:00
Edward Betts 4fa7647584 Add ferry terminal icon 2024-05-01 15:00:24 +03:00
Edward Betts afa2a2e934 Show ferry routes and terminals on the map 2024-05-01 11:59:21 +03:00
Edward Betts b9b849802d Show ferry bookings in trip list 2024-05-01 08:32:15 +02:00
Edward Betts a5d1290491 Use cached FX rate if fresh rate not available 2024-05-01 08:31:14 +02:00
Edward Betts 2b822e28a0 Hide booking reference if not logged in 2024-04-20 14:39:56 +01:00
Edward Betts 6d6e416df3 Adjust travel page to show flights grouped by booking with booking reference and price
Closes: #152
2024-04-20 14:17:32 +01:00
Edward Betts 19732a3ef1 Update to read flights grouped by booking
Closes: #151
2024-04-20 10:23:56 +01:00
Edward Betts 5ab9d93484 Convert prices to GBP and show
Closes: #120
2024-04-20 07:54:29 +01:00
Edward Betts dbc12adb3d Rewrite update code to use flask app_context() 2024-04-20 07:52:00 +01:00
Edward Betts 66ca6c0744 Get more exchange rates 2024-04-18 22:26:04 +01:00
Edward Betts b0381db3b5 Add docstrings and types 2024-04-18 11:56:51 +01:00
Edward Betts 0fcaf76104 Simplify code 2024-04-17 14:48:18 +01:00
Edward Betts 32e07d4ce4 More code re-use 2024-04-17 14:33:23 +01:00
Edward Betts d28e172a8c Add 'free' badge to conferences 2024-04-17 11:49:18 +01:00
Edward Betts e2afe0ffa4 Show prices for logged in users
Trip prices are visible on trip list, accommodation list,
conference list and travel list.

Prices are hidden if not logged in, except conference prices.

Still need to show prices on individual trip page.
2024-04-17 11:40:13 +01:00
Edward Betts dbffd60937 Ensure all pages have a title
Closes: #117
2024-04-16 22:01:16 +01:00
Edward Betts 875f50e684 Fix LUX country code in ESA list 2024-04-16 21:52:30 +01:00
Edward Betts e1688629a3 Trip distance by means of transport: air and rail
Closes: #148
2024-04-16 12:41:00 +01:00
Edward Betts dce8fde29a Update template 2024-04-16 12:12:19 +01:00
Edward Betts ab60721e15 Move code around a bit 2024-04-16 12:08:14 +01:00
Edward Betts b1507702cf Tidy code for conference list 2024-04-08 09:11:22 +02:00
Edward Betts 291b545915 Split accommodation list: past, current, future
Closes: #145
2024-04-08 09:08:57 +02:00
Edward Betts 37be85593b Show distances on trip pages
Closes: #144
2024-04-06 16:13:07 +02:00
Edward Betts eb3be4cb51 Show flight distance 2024-04-06 09:26:09 +02:00
Edward Betts 87aaba64b2 Calculate flight distances 2024-04-06 09:25:32 +02:00
Edward Betts a7296c943b Show trip total distance on trip list page
Closes: #142
2024-04-06 09:24:10 +02:00
Edward Betts fe4bde32ba Include route distances on trips page 2024-04-05 20:17:19 +02:00
Edward Betts b5c1e16901 Less precision for distances 2024-04-05 15:10:43 +01:00
Edward Betts 48549ce009 Bug fix travel template grid layout 2024-04-05 16:00:51 +02:00
Edward Betts 8ef67e0cee Add train route distance info 2024-04-05 15:58:44 +02:00
Edward Betts 5964899a00 Validate YAML to check events 2024-04-05 11:23:29 +02:00
Edward Betts a607f29259 Validate YAML to catch bad train rotues
Closes: #143
2024-04-05 11:21:51 +02:00
Edward Betts e5325a0392 Hide booking URLs on calendar if not logged in 2024-04-02 10:42:06 +01:00
Edward Betts 7208e10cb2 Hide booking URLs if not logged in 2024-04-02 10:37:06 +01:00
Edward Betts ae630a8f68 Show links for train journeys
Closes: #79
2024-04-01 10:36:48 +01:00
Edward Betts f0c28d2440 Download domain info from gandi 2024-03-30 19:32:05 +00:00
Edward Betts 748ec3a1bc pass gandi domain end date as date 2024-03-30 19:31:48 +00:00
Edward Betts d813bff812 dockbot is optional 2024-03-30 19:31:29 +00:00
Edward Betts ebd46a7a21 Add empty index.js for webpack 2024-03-30 10:19:54 +00:00
Edward Betts efc660b0ac Avoid CDN for frontend CSS and Javascript
Closes: #137
2024-03-30 10:18:21 +00:00
Edward Betts 422cd8aa9d Use gandi API to get domain renewal dates
Closes: #134
2024-03-27 17:47:48 +00:00
Edward Betts 1e90df76dd Bug fix 2024-03-27 16:38:22 +00:00
Edward Betts 6018f0217d Make use of CDN optional 2024-03-27 16:37:26 +00:00
Edward Betts cff981eb8b Adjust default event duration to be 30 minutes 2024-03-27 16:37:26 +00:00
Edward Betts 826eafbc86 Add UnknownStation Exception 2024-03-27 16:37:26 +00:00
Edward Betts d690442f0f Add option for unpublished trips 2024-03-26 14:54:02 +00:00
Edward Betts e3cae68d2f Flight arrive can be missing 2024-03-12 15:10:17 +00:00
Edward Betts 9d691bee40 Fix trip page that crashes when showing Unicode Kosovo flag
Closes: #139
2024-03-12 15:09:53 +00:00
Edward Betts 4ebb08f68e Add command line utility to validate YAML 2024-03-11 15:58:56 +00:00
Edward Betts f1338e5970 Handle rail journeys without specific time 2024-03-11 10:53:55 +01:00
Edward Betts 1ed6c50ad8 Only show trains with a full departure datetime 2024-03-11 10:46:18 +01:00
Edward Betts 96ab89b42f No need for emojis for free weekends 2024-03-05 11:29:52 +00:00
Edward Betts ff15f380fa Consider current trips for free weekends list
Closes: #138
2024-03-05 12:28:34 +01:00
Edward Betts a37af733cd Combine cron update scripts into one script
Closes: #135
2024-03-05 10:26:54 +01:00
Edward Betts 4ade643de6 Move UPRN and postcode values to config
Closes: #136
2024-03-05 10:07:28 +01:00
Edward Betts 7d5cfe859a Don't show prices for travel and accommodation if not authenticated 2024-03-05 08:01:17 +01:00
Edward Betts 0e7a4c2386 Fix incorrect docstring 2024-03-05 07:44:12 +01:00
Edward Betts 5fdfd9d533 Generate trip titles from railway station names 2024-02-28 15:49:48 +00:00
Edward Betts 8f749c8e35 Allow unprivileged view
Closes: #101
2024-02-25 09:08:19 +00:00
Edward Betts f3f9ee5bf9 Merge branch 'main' of https://git.4angle.com/edward/agenda 2024-02-21 13:08:59 +00:00
Edward Betts 5ffb389c53 Add weekend availability view
Closes: #130
2024-02-21 13:06:40 +00:00
Edward Betts 38dccc1529 Fix trip page layout on mobile 2024-02-19 09:46:57 +00:00
Edward Betts 7a9fbcec7b Catch errors from external service and display in alert box
Closes: #129
2024-02-18 22:36:15 +00:00
Edward Betts f19e4e4dd4 Include UniAuth callback 2024-02-18 22:07:38 +00:00
Edward Betts b66f852256 Avoid space launches with vague dates in agenda
Closes: #127
2024-02-11 07:42:45 +00:00
Edward Betts 3163bca99b Read extra headers for mail from config 2024-02-11 07:15:08 +00:00
Edward Betts f54c9cfbb7 Switch to using cards for trip pay layout
Closes: #125
2024-01-30 11:07:28 +00:00
Edward Betts f3304d0ffe We don't need to show GBPUSD 2024-01-30 10:36:42 +00:00
Edward Betts 8b777e64fc Add page to generate a list of trips as text 2024-01-30 10:35:57 +00:00
Edward Betts 6c8e1bf48d Add new conference field 2024-01-25 16:48:31 +00:00
Edward Betts 89ff92c533 Show linked events on trip page
Closes: #124
2024-01-24 12:03:56 +00:00
Edward Betts 14c25e16ed Add next and previous links at top of trip page 2024-01-23 16:28:20 +00:00
Edward Betts 6c1c638104 Gap page to show trips
Closes: #90
Closes: #97
2024-01-23 15:59:09 +00:00
Edward Betts d6ebd86232 Add more emojis 2024-01-23 15:57:36 +00:00
Edward Betts f76f9e03da Add trip country_flags method 2024-01-23 15:57:12 +00:00
Edward Betts 6475692db1 Consider accommodation for trip end date 2024-01-23 15:56:23 +00:00
Edward Betts 72e7945fbe Change layout of trip page 2024-01-23 15:55:49 +00:00
Edward Betts fc36647d49 Switch to UniAuth.auth 2024-01-23 10:49:58 +00:00
Edward Betts 5f0d2e884f Add Rio Carnival to agenda 2024-01-22 14:13:02 +00:00
Edward Betts 7e51a32210 Trip page to show how many days/weeks/months until trip
Closes #118
2024-01-22 13:12:33 +00:00
Edward Betts b7d655a21e Conference CFP end dates as events
Closes: #122
2024-01-22 13:04:08 +00:00
Edward Betts f028e40df8 Show trip nights
Closes: #119
2024-01-22 12:47:22 +00:00
Edward Betts b4a79cae69 Add logout link
Closes: #123
2024-01-22 12:46:46 +00:00
Edward Betts cc3dc81bdb Merge branch 'main' of https://git.4angle.com/edward/agenda 2024-01-22 12:46:13 +00:00
Edward Betts bdaad42eba Rename UNIAUTH_URL setting 2024-01-22 12:43:32 +00:00
Edward Betts 389092cbb4 Option to disable auth for testing 2024-01-22 12:43:09 +00:00
Edward Betts d41d53367f Redirect back to agenda after login
Closes: #91
2024-01-21 16:23:46 +00:00
Edward Betts 533c7767e8 Merge branch 'main' of https://git.4angle.com/edward/agenda 2024-01-21 15:56:22 +00:00
Edward Betts ac32b4fe89 Bug fix 2024-01-21 15:56:18 +00:00
Edward Betts 2b89ff7ff9 Add authentication via UniAuth 2024-01-21 15:55:31 +00:00
Edward Betts 6d65f5045e Ensure space launch JSON can be parsed before saving 2024-01-21 08:07:11 +00:00
Edward Betts 566b09f888 Don't bother with httpx for the space launch API 2024-01-19 21:08:50 +00:00
Edward Betts 073f452356 Tidy template 2024-01-19 20:49:48 +00:00
Edward Betts cd60ebdea2 Show days until holiday on holidays page 2024-01-19 20:47:03 +00:00
Edward Betts e16e04ab51 Show more detail on space launch page 2024-01-19 20:35:52 +00:00
Edward Betts e475f98dd6 Use cache for space launch data 2024-01-19 19:53:21 +00:00
Edward Betts 98b7c4a89d Move timing info to the end of the page 2024-01-19 07:57:56 +00:00
Edward Betts a998e456eb Add a page title to trip pages 2024-01-17 20:56:42 +00:00
Edward Betts ed36170eb7 Shift maps over a bit to make it easier to scroll the page 2024-01-17 12:41:45 +00:00
Edward Betts 8a98f4d2db Fix conference column counts on trip pages 2024-01-16 20:43:28 +00:00
Edward Betts ec99289cfa Show conference CFP deadlines
Closes: #105
2024-01-16 20:17:05 +00:00
Edward Betts 6748f8338c Not flying to Belgium 2024-01-16 18:12:48 +00:00
Edward Betts 549ddd3b60 Hide holidays if empty 2024-01-16 18:10:13 +00:00
Edward Betts 44bf744361 Show holidays in visited countries on trip page
Closes: #112
2024-01-16 18:08:50 +00:00
Edward Betts 4b6f4231b7 Bug fix holiday start and end dates 2024-01-16 18:08:11 +00:00
Edward Betts 3a7784bb25 Show unbooked flights in orange
Closes: #114
2024-01-16 17:11:55 +00:00
Edward Betts 39f9c98a51 Include heathrow airport pin on map for conference without booked flights
Closes: #115
2024-01-16 17:07:59 +00:00
Edward Betts b33da8485c Include local names on holiday list
Closes: #106
2024-01-16 16:22:04 +00:00
Edward Betts 75d18aed2b Add more emojis 2024-01-16 16:00:41 +00:00
Edward Betts 4638069e51 Move emojis into one place
Closes: #113
2024-01-16 15:32:39 +00:00
Edward Betts 8047cb67fe Bug fix for data from space launch API 2024-01-16 15:05:59 +00:00
Edward Betts 2a1e2429d7 Only show date if different from previous row 2024-01-16 14:23:31 +00:00
Edward Betts 1e9ae2091e Show a geodesic line from LHR to the conference venue if no travel booked
Closes: #111
2024-01-16 12:26:27 +00:00
Edward Betts 322b65237d Add holiday page
Page showing holidays in countries of interest, just in English for now.
2024-01-16 12:06:46 +00:00
Edward Betts 69e10db8ef Refactor 2024-01-16 11:35:38 +00:00
Edward Betts 8df94aaafb Code to run from cron to update bank holiday list 2024-01-16 11:12:43 +00:00
Edward Betts 3cb03a787c Add docstring 2024-01-16 11:12:13 +00:00
Edward Betts c6cc3fc558 Polyfill to make map work on old browsers 2024-01-16 09:00:03 +00:00
Edward Betts b061262120 Split code for holidays into separate file 2024-01-16 07:42:44 +00:00
Edward Betts a6a78d18e5 Bug fix conferences can have lat/lon 2024-01-14 21:57:54 +00:00
Edward Betts e6cffdd3d5 Show venue pins on the map
Closes: #108
2024-01-14 21:43:10 +00:00
Edward Betts f8658a7850 Put map icons in a circle 2024-01-14 21:28:56 +00:00
Edward Betts 1f8d465c6d Show accommodation pins on the map 2024-01-14 21:28:12 +00:00
Edward Betts 7ca5eafd1d Show accommodation pins on the map 2024-01-14 21:00:19 +00:00
Edward Betts f60a1a329c Link to more flight info sites 2024-01-14 18:16:20 +00:00
Edward Betts 283a9d0b27 Link from accommodation list to trip pages
Closes: #109
2024-01-14 17:57:02 +00:00
Edward Betts 31e8197c79 Link from conference list to trip pages 2024-01-14 17:23:50 +00:00
Edward Betts 36168843d6 Adjust template layout 2024-01-14 16:52:06 +00:00
Edward Betts 7883c89b76 Refactor 2024-01-14 16:50:16 +00:00
Edward Betts f3a4f1dcd1 Use unpkg.com as CDN to be consistent 2024-01-14 15:42:48 +00:00
Edward Betts e86bd69ddb Show number of days between trips 2024-01-14 12:35:15 +00:00
Edward Betts fbee775f5b Next trip and previous trip links on trip pages
Closes: #110
2024-01-14 12:29:39 +00:00
Edward Betts 36b5d38274 Show map of past trips 2024-01-14 12:17:22 +00:00
Edward Betts bd61b1bccd Move map code into dedicated JS file 2024-01-14 12:01:33 +00:00
Edward Betts fab478dc61 Rename trips.html template to trip_list.html 2024-01-14 10:32:52 +00:00
Edward Betts fd34190368 Map of all upcoming travel on trips page
Closes: #107
2024-01-14 10:31:51 +00:00
Edward Betts 4a990a9fe5 Move trip code into separate file 2024-01-14 10:14:05 +00:00
Edward Betts e0735b4185 Refresh space launches from cron because API is slow
Closes: #98
2024-01-14 08:04:05 +00:00
Edward Betts cb2a2c7fb8 Change colour of rail journey on map to blue 2024-01-13 18:20:02 +00:00
Edward Betts e2fdd1d198 Show rail routes using GeoJSON 2024-01-12 22:29:26 +00:00
Edward Betts 4e719a07ab Show flights and trains in different colours 2024-01-12 19:52:00 +00:00
Edward Betts 4b62ec96dc Make the map bigger 2024-01-12 19:43:22 +00:00
Edward Betts e993329939 Show lines connecting transport stops on map
Closes: #104
2024-01-12 17:17:12 +00:00
Edward Betts 4b8b1f7556 Show station and airport icons on the map 2024-01-12 16:54:52 +00:00
Edward Betts 0c02d9c899 Include airport pins on the map 2024-01-12 16:20:36 +00:00
Edward Betts 60070d07fd Add maps to trip pages
Closes: #102
2024-01-12 15:04:08 +00:00
Edward Betts a9c9c719a4 Return 404 not found for invalid trip IDs
Closes: #103
2024-01-12 14:08:36 +00:00
Edward Betts 2744f67987 Add pages for individual trips
Closes: #100
2024-01-12 14:04:06 +00:00
Edward Betts ad47f291f8 Add events to trips 2024-01-10 13:27:25 +00:00
Edward Betts 8504a3a022 Add radarbox.com links for flights 2024-01-10 13:26:59 +00:00
Edward Betts 82de51109f import datetime 2024-01-10 13:06:29 +00:00
Edward Betts 199eb82bce Add refresh option for Bristol waste schedule 2024-01-08 15:45:08 +00:00
Edward Betts 7456f72325 Update Bristol bins from cron to save time
Closes: #96
2024-01-08 15:43:31 +00:00
Edward Betts 1453c4015c Show timings for index page data gathering 2024-01-08 15:22:16 +00:00
Edward Betts cd0ffb3390 Hide LHG run club when on a trip
Closes: #95
2024-01-08 15:20:48 +00:00
Edward Betts 3d16e30aa8 Try and make mypy happy about types 2024-01-08 15:19:20 +00:00
Edward Betts acbad39df7 Download bank-holidays.json if the local copy is unreadable 2024-01-08 15:18:28 +00:00
Edward Betts 7a5319aa83 Improve trip template layout 2024-01-06 15:39:46 +00:00
Edward Betts 50127417f0 Show trip country list in order visited 2024-01-06 09:21:54 +00:00
Edward Betts fd6d3b674b Split up trips page and sort like conference page
Closes: #94
2024-01-06 09:17:34 +00:00
Edward Betts 21b67bdc64 Show end date for trips 2024-01-05 09:35:56 +00:00
Edward Betts ea33722f69 Add missing template 2024-01-04 22:57:05 +00:00
Edward Betts 3dddc52430 Merge branch 'main' of git.4angle.com:edward/agenda 2024-01-04 22:56:14 +00:00
Edward Betts ce9faa654f Add trips page
Creating a new entity called a trip. This will group together any travel
accommodation and conferences that happen together on one trip.

A trip is assumed to start when leaving home and finish when returning
home.

The start date of a trip in is the trip ID. The date is written in ISO
format.

This assumes there cannot be multiple trips one one day. This assumption
might be wrong, for example a morning day trip by rail, then another
trip starts in the afternoon. I can change my choice of using dates as
trip IDs if that happens.

Sometimes during the planning of a trip the start date is unknown. For
now we make up a start date, we can always change it later. If we use
the start date in URLs then the URLs will change. Might need to keep a
file of redirects, or could think of a different style of identifier.

Trip ID have been added to accommodation, conferences, trains and
flights.

Later there will be a trips.yaml with notes about each trip.
2024-01-04 22:56:07 +00:00
Edward Betts d9b1d77872 Add trips page
Creating a new entity called a trip. This will group together any travel
accommodation and conferences that happen together on one trip.

A trip is assumed to start when leaving home and finish when returning
home.

The start date of a trip in is the trip ID. The date is written in ISO
format.

This assumes there cannot be multiple trips one one day. This assumption
might be wrong, for example a morning day trip by rail, then another
trip starts in the afternoon. I can change my choice of using dates as
trip IDs if that happens.

Sometimes during the planning of a trip the start date is unknown. For
now we make up a start date, we can always change it later. If we use
the start date in URLs then the URLs will change. Might need to keep a
file of redirects, or could think of a different style of identifier.

Trip ID have been added to accommodation, conferences, trains and
flights.

Later there will be a trips.yaml with notes about each trip.
2024-01-04 22:55:19 +00:00
Edward Betts 5786e3d575 Make conferences a top-level list 2024-01-04 15:08:12 +00:00
Edward Betts b1139b79d2 Make name a link to conference web site 2024-01-04 07:40:39 +00:00
Edward Betts 824285a4cf Add links to accommodation 2024-01-03 17:22:04 +00:00
Edward Betts 17036d849f Show country names and flags on conference page 2024-01-03 15:52:24 +00:00
Edward Betts fd46f0a405 Show country names and flags on accommodation page 2024-01-03 11:33:24 +00:00
Edward Betts 9800030201 Split space launches into separate page
Closes: #93
2024-01-03 09:13:58 +00:00
Edward Betts 2b3e4f8d72 Fix navbar on mobile 2024-01-02 18:56:13 +00:00
59 changed files with 5057 additions and 1090 deletions

17
.eslintrc.js Normal file
View file

@ -0,0 +1,17 @@
module.exports = {
"env": {
"browser": true,
"es6": true
},
"extends": "eslint:recommended",
"globals": {
"Atomics": "readonly",
"SharedArrayBuffer": "readonly"
},
"parserOptions": {
"ecmaVersion": 14,
"sourceType": "module"
},
"rules": {
}
};

View file

@ -2,6 +2,7 @@
from datetime import date, datetime, time
import pycountry
import pytz
uk_tz = pytz.timezone("Europe/London")
@ -10,3 +11,31 @@ uk_tz = pytz.timezone("Europe/London")
def uk_time(d: date, t: time) -> datetime:
"""Combine time and date for UK timezone."""
return uk_tz.localize(datetime.combine(d, t))
def format_list_with_ampersand(items: list[str]) -> str:
"""Join a list of strings with commas and an ampersand."""
if len(items) > 1:
return ", ".join(items[:-1]) + " & " + items[-1]
elif items:
return items[0]
return ""
def get_country(alpha_2: str) -> pycountry.db.Country | None:
"""Lookup country by alpha-2 country code."""
if alpha_2.count(",") > 10: # ESA
return pycountry.db.Country(flag="🇪🇺", name="ESA")
if not alpha_2:
return None
if alpha_2 == "xk":
return pycountry.db.Country(
flag="\U0001F1FD\U0001F1F0", name="Kosovo", alpha_2="xk"
)
country: pycountry.db.Country
if len(alpha_2) == 2:
country = pycountry.countries.get(alpha_2=alpha_2.upper())
elif len(alpha_2) == 3:
country = pycountry.countries.get(alpha_3=alpha_2.upper())
return country

View file

@ -1,4 +1,4 @@
"""Accomodation"""
"""Accommodation."""
import yaml
@ -6,15 +6,14 @@ from .types import Event
def get_events(filepath: str) -> list[Event]:
"""Get accomodation from YAML."""
"""Get accommodation from YAML."""
with open(filepath) as f:
return [
Event(
date=item["from"],
end_date=item["to"],
name="accommodation",
title="🧳"
+ (
title=(
f'{item["location"]} Airbnb'
if item.get("operator") == "airbnb"
else item["name"]

View file

@ -42,7 +42,7 @@ def get_birthdays(from_date: date, filepath: str) -> list[Event]:
Event(
date=bday.replace(year=bday.year + offset),
name="birthday",
title=f'🎈 {entity["label"]} ({display_age})',
title=f'{entity["label"]} ({display_age})',
)
)

129
agenda/bristol_waste.py Normal file
View file

@ -0,0 +1,129 @@
"""Waste collection schedules."""
import json
import os
import typing
from collections import defaultdict
from datetime import date, datetime, timedelta
import httpx
from .types import Event
from .utils import make_waste_dir
ttl_hours = 12
BristolSchedule = list[dict[str, typing.Any]]
async def get(
start_date: date, data_dir: str, uprn: str, refresh: bool = False
) -> list[Event]:
"""Get waste collection schedule from Bristol City Council."""
by_date: defaultdict[date, list[str]] = defaultdict(list)
for item in await get_data(data_dir, uprn, refresh):
service = get_service(item)
for d in collections(item):
if d < start_date and service not in by_date[d]:
by_date[d].append(service)
return [
Event(name="waste_schedule", date=d, title="Bristol: " + ", ".join(services))
for d, services in by_date.items()
]
async def get_data(data_dir: str, uprn: str, refresh: bool = False) -> BristolSchedule:
"""Get Bristol Waste schedule, with cache."""
now = datetime.now()
waste_dir = os.path.join(data_dir, "waste")
make_waste_dir(data_dir)
existing_data = os.listdir(waste_dir)
existing = [f for f in existing_data if f.endswith(f"_{uprn}.json")]
if existing:
recent_filename = max(existing)
recent = datetime.strptime(recent_filename, f"%Y-%m-%d_%H:%M_{uprn}.json")
delta = now - recent
def get_from_recent() -> BristolSchedule:
json_data = json.load(open(os.path.join(waste_dir, recent_filename)))
return typing.cast(BristolSchedule, json_data["data"])
if not refresh and existing and delta < timedelta(hours=ttl_hours):
return get_from_recent()
try:
r = await get_web_data(uprn)
except httpx.ReadTimeout:
return get_from_recent()
with open(f'{waste_dir}/{now.strftime("%Y-%m-%d_%H:%M")}_{uprn}.json', "wb") as out:
out.write(r.content)
return typing.cast(BristolSchedule, r.json()["data"])
async def get_web_data(uprn: str) -> httpx.Response:
"""Get JSON from Bristol City Council."""
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
HEADERS = {
"Accept": "*/*",
"Accept-Language": "en-GB,en;q=0.9",
"Connection": "keep-alive",
"Ocp-Apim-Subscription-Key": "47ffd667d69c4a858f92fc38dc24b150",
"Ocp-Apim-Trace": "true",
"Origin": "https://bristolcouncil.powerappsportals.com",
"Referer": "https://bristolcouncil.powerappsportals.com/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
"Sec-GPC": "1",
"User-Agent": UA,
}
_uprn = str(uprn).zfill(12)
async with httpx.AsyncClient(timeout=20) as client:
# Initialise form
payload = {"servicetypeid": "7dce896c-b3ba-ea11-a812-000d3a7f1cdc"}
response = await client.get(
"https://bristolcouncil.powerappsportals.com/completedynamicformunauth/",
headers=HEADERS,
params=payload,
)
host = "bcprdapidyna002.azure-api.net"
# Set the search criteria
payload = {"Uprn": "UPRN" + _uprn}
response = await client.post(
f"https://{host}/bcprdfundyna001-llpg/DetailedLLPG",
headers=HEADERS,
json=payload,
)
# Retrieve the schedule
payload = {"uprn": _uprn}
response = await client.post(
f"https://{host}/bcprdfundyna001-alloy/NextCollectionDates",
headers=HEADERS,
json=payload,
)
return response
def get_service(item: dict[str, typing.Any]) -> str:
"""Bristol waste service name."""
service: str = item["containerName"]
return "Recycling" if "Recycling" in service else service.partition(" ")[2]
def collections(item: dict[str, typing.Any]) -> typing.Iterable[date]:
"""Bristol dates from collections."""
for collection in item["collection"]:
for collection_date_key in ["nextCollectionDate", "lastCollectionDate"]:
yield date.fromisoformat(collection[collection_date_key][:10])

159
agenda/busy.py Normal file
View file

@ -0,0 +1,159 @@
"""Identify busy events and gaps when nothing is scheduled."""
import itertools
import typing
from datetime import date, datetime, timedelta
import flask
from . import events_yaml
from .types import Event, StrDict, Trip
def busy_event(e: Event) -> bool:
"""Busy."""
if e.name not in {
"event",
"accommodation",
"conference",
"transport",
"meetup",
"party",
"trip",
}:
return False
if e.title in ("IA UK board meeting", "Mill Road Winter Fair"):
return False
if e.name == "conference" and not e.going:
return False
if not e.title:
return True
if e.title == "LHG Run Club" or "Third Thursday Social" in e.title:
return False
lc_title = e.title.lower()
return (
"rebels" not in lc_title
and "south west data social" not in lc_title
and "dorkbot" not in lc_title
)
def get_busy_events(
today: date, config: flask.config.Config, trips: list[Trip]
) -> list[Event]:
"""Find busy events from a year ago to two years in the future."""
last_year = today - timedelta(days=365)
next_year = today + timedelta(days=2 * 365)
my_data = config["PERSONAL_DATA"]
events = events_yaml.read(my_data, last_year, next_year, skip_trips=True)
for trip in trips:
event_type = "trip"
if trip.events and not trip.conferences:
event_type = trip.events[0]["name"]
elif len(trip.conferences) == 1 and trip.conferences[0].get("hackathon"):
event_type = "hackathon"
events.append(
Event(
name=event_type,
title=trip.title + " " + trip.country_flags,
date=trip.start,
end_date=trip.end,
url=flask.url_for("trip_page", start=trip.start.isoformat()),
)
)
busy_events = [
e
for e in sorted(events, key=lambda e: e.as_date)
if (e.as_date >= today or (e.end_date and e.end_as_date >= today))
and e.as_date < next_year
and busy_event(e)
]
return busy_events
def weekends(busy_events: list[Event]) -> typing.Sequence[StrDict]:
"""Next ten weekends."""
today = datetime.today()
weekday = today.weekday()
# Calculate the difference to the next or previous Saturday
if weekday == 6: # Sunday
start_date = (today - timedelta(days=1)).date()
else:
start_date = (today + timedelta(days=(5 - weekday))).date()
weekends_info = []
for i in range(52):
saturday = start_date + timedelta(weeks=i)
sunday = saturday + timedelta(days=1)
saturday_events = [
event
for event in busy_events
if event.end_date and event.as_date <= saturday <= event.end_as_date
]
sunday_events = [
event
for event in busy_events
if event.end_date and event.as_date <= sunday <= event.end_as_date
]
weekends_info.append(
{"date": saturday, "saturday": saturday_events, "sunday": sunday_events}
)
return weekends_info
def find_gaps(events: list[Event], min_gap_days: int = 3) -> list[StrDict]:
"""Gaps of at least `min_gap_days` between events in a list of events."""
# Sort events by start date
gaps: list[tuple[date, date]] = []
previous_event_end = None
by_start_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.as_date)
}
by_end_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.end_as_date)
}
for event in events:
# Use start date for current event
start_date = event.as_date
# If previous event exists, calculate the gap
if previous_event_end:
gap_days = (start_date - previous_event_end).days
if gap_days >= (min_gap_days + 2):
start_end = (
previous_event_end + timedelta(days=1),
start_date - timedelta(days=1),
)
gaps.append(start_end)
# Update previous event end date
end = event.end_as_date
if not previous_event_end or end > previous_event_end:
previous_event_end = end
return [
{
"start": gap_start,
"end": gap_end,
"after": by_start_date[gap_end + timedelta(days=1)],
"before": by_end_date[gap_start - timedelta(days=1)],
}
for gap_start, gap_end in gaps
]

View file

@ -36,7 +36,7 @@ def build_events(events: list[Event]) -> list[dict[str, typing.Any]]:
assert e.title and e.end_date
item = {
"allDay": True,
"title": e.display_title,
"title": e.title_with_emoji,
"start": e.as_date.isoformat(),
"end": (e.end_as_date + one_day).isoformat(),
"url": e.url,
@ -61,12 +61,12 @@ def build_events(events: list[Event]) -> list[dict[str, typing.Any]]:
continue
if e.has_time:
end = e.end_date or e.date + timedelta(hours=1)
end = e.end_date or e.date + timedelta(minutes=30)
else:
end = (e.end_as_date if e.end_date else e.as_date) + one_day
item = {
"allDay": not e.has_time,
"title": e.display_title,
"title": e.title_with_emoji,
"start": e.date.isoformat(),
"end": end.isoformat(),
}

33
agenda/carnival.py Normal file
View file

@ -0,0 +1,33 @@
"""Calculate the date for carnival."""
from datetime import date, timedelta
from dateutil.easter import easter
from .types import Event
def rio_carnival_events(start_date: date, end_date: date) -> list[Event]:
"""List of events for Rio Carnival for each year between start_date and end_date."""
events = []
for year in range(start_date.year, end_date.year + 1):
easter_date = easter(year)
carnival_start = easter_date - timedelta(days=51)
carnival_end = easter_date - timedelta(days=46)
# Only include the carnival if it falls within the specified date range
if (
start_date <= carnival_start <= end_date
or start_date <= carnival_end <= end_date
):
events.append(
Event(
name="carnival",
title="Rio Carnival",
date=carnival_start,
end_date=carnival_end,
url="https://en.wikipedia.org/wiki/Rio_Carnival",
)
)
return events

View file

@ -18,6 +18,8 @@ class Conference:
location: str
start: date | datetime
end: date | datetime
trip: date | None = None
country: str | None = None
venue: str | None = None
address: str | None = None
url: str | None = None
@ -29,6 +31,15 @@ class Conference:
online: bool = False
price: decimal.Decimal | None = None
currency: str | None = None
latitude: float | None = None
longitude: float | None = None
cfp_end: date | None = None
cfp_url: str | None = None
free: bool | None = None
hackathon: bool | None = None
ticket_type: str | None = None
attendees: int | None = None
hashtag: str | None = None
@property
def display_name(self) -> str:
@ -42,17 +53,25 @@ class Conference:
def get_list(filepath: str) -> list[Event]:
"""Read conferences from a YAML file and return a list of Event objects."""
return [
Event(
events: list[Event] = []
for conf in (Conference(**conf) for conf in yaml.safe_load(open(filepath, "r"))):
event = Event(
name="conference",
date=conf.start,
end_date=conf.end,
title=f"🎤 {conf.display_name}",
title=conf.display_name,
url=conf.url,
going=conf.going,
)
for conf in (
Conference(**conf)
for conf in yaml.safe_load(open(filepath, "r"))["conferences"]
events.append(event)
if not conf.cfp_end:
continue
cfp_end_event = Event(
name="cfp_end",
date=conf.cfp_end,
title="CFP end: " + conf.display_name,
url=conf.cfp_url or conf.url,
)
]
events.append(cfp_end_event)
return events

View file

@ -1,51 +1,49 @@
"""Agenda data."""
import asyncio
import collections
import itertools
import os
import typing
from datetime import date, datetime, timedelta
from time import time
import dateutil.rrule
import dateutil.tz
import flask
import holidays
import isodate # type: ignore
import lxml
import pytz
import yaml
from . import (
accommodation,
birthday,
calendar,
bristol_waste,
busy,
carnival,
conference,
domains,
economist,
fx,
events_yaml,
gandi,
gwr,
hn,
holidays,
meetup,
n_somerset_waste,
stock_market,
subscription,
sun,
thespacedevs,
travel,
uk_holiday,
uk_tz,
waste_schedule,
)
from .types import Event, Holiday
StrDict = dict[str, typing.Any]
from .types import Event, StrDict
from .utils import time_function
here = dateutil.tz.tzlocal()
# deadline to file tax return
# credit card expiry dates
# morzine ski lifts
# chalet availablity calendar
# chalet availability calendar
# starlink visible
@ -62,298 +60,113 @@ def timezone_transition(
]
def us_holidays(start_date: date, end_date: date) -> list[Holiday]:
"""Get US holidays."""
found: list[Holiday] = []
for year in range(start_date.year, end_date.year + 1):
hols = holidays.country_holidays("US", years=year, language="en")
found += [
Holiday(date=hol_date, name=title, country="us")
for hol_date, title in hols.items()
if start_date < hol_date < end_date
]
extra = []
for h in found:
if h.name != "Thanksgiving":
continue
extra += [
Holiday(date=h.date + timedelta(days=1), name="Black Friday", country="us"),
Holiday(date=h.date + timedelta(days=4), name="Cyber Monday", country="us"),
]
return found + extra
def get_nyse_holidays(
start_date: date, end_date: date, us_hols: list[Holiday]
async def n_somerset_waste_collection_events(
data_dir: str, postcode: str, uprn: str
) -> list[Event]:
"""NYSE holidays."""
known_us_hols = {(h.date, h.name) for h in us_hols}
found: list[Event] = []
rename = {"Thanksgiving Day": "Thanksgiving"}
for year in range(start_date.year, end_date.year + 1):
hols = holidays.financial_holidays("NYSE", years=year)
found += [
Event(
name="holiday",
date=hol_date,
title=rename.get(title, title),
)
for hol_date, title in hols.items()
if start_date < hol_date < end_date
]
found = [hol for hol in found if (hol.date, hol.title) not in known_us_hols]
for hol in found:
assert hol.title
hol.title += " (NYSE)"
return found
def get_holidays(country: str, start_date: date, end_date: date) -> list[Holiday]:
"""Get holidays."""
found: list[Holiday] = []
for year in range(start_date.year, end_date.year + 1):
hols = holidays.country_holidays(country.upper(), years=year, language="en_US")
found += [
Holiday(
date=hol_date,
name=title,
country=country.lower(),
)
for hol_date, title in hols.items()
if start_date < hol_date < end_date
]
return found
def midnight(d: date) -> datetime:
"""Convert from date to midnight on that day."""
return datetime.combine(d, datetime.min.time())
def dates_from_rrule(
rrule: str, start: date, end: date
) -> typing.Sequence[datetime | date]:
"""Generate events from an RRULE between start_date and end_date."""
all_day = not any(param in rrule for param in ["BYHOUR", "BYMINUTE", "BYSECOND"])
return [
i.date() if all_day else uk_tz.localize(i)
for i in dateutil.rrule.rrulestr(rrule, dtstart=midnight(start)).between(
midnight(start), midnight(end)
)
]
async def waste_collection_events(data_dir: str) -> list[Event]:
"""Waste colllection events."""
postcode = "BS48 3HG"
uprn = "24071046"
html = await waste_schedule.get_html(data_dir, postcode, uprn)
html = await n_somerset_waste.get_html(data_dir, postcode, uprn)
root = lxml.html.fromstring(html)
events = waste_schedule.parse(root)
events = n_somerset_waste.parse(root)
return events
async def bristol_waste_collection_events(
data_dir: str, start_date: date
data_dir: str, start_date: date, uprn: str
) -> list[Event]:
"""Waste colllection events."""
uprn = "358335"
return await waste_schedule.get_bristol_gov_uk(start_date, data_dir, uprn)
return await bristol_waste.get(start_date, data_dir, uprn)
def combine_holidays(holidays: list[Holiday]) -> list[Event]:
"""Combine UK and US holidays with the same date and title."""
all_countries = {h.country for h in holidays}
standard_name = {
(1, 1): "New Year's Day",
(1, 6): "Epiphany",
(5, 1): "Labour Day",
(8, 15): "Assumption Day",
(12, 8): "Immaculate conception",
(12, 25): "Christmas Day",
(12, 26): "Boxing Day",
}
combined: collections.defaultdict[
tuple[date, str], set[str]
] = collections.defaultdict(set)
for h in holidays:
assert isinstance(h.name, str) and isinstance(h.date, date)
event_key = (h.date, standard_name.get((h.date.month, h.date.day), h.name))
combined[event_key].add(h.country)
events: list[Event] = []
for (d, name), countries in combined.items():
if len(countries) == len(all_countries):
country_list = ""
elif len(countries) < len(all_countries) / 2:
country_list = ", ".join(sorted(country.upper() for country in countries))
else:
country_list = "not " + ", ".join(
sorted(country.upper() for country in all_countries - set(countries))
)
e = Event(
name="holiday",
date=d,
title=f"{name} ({country_list})" if country_list else name,
)
events.append(e)
return events
def get_yaml_event_date_field(item: dict[str, str]) -> str:
"""Event date field name."""
return (
"end_date"
if item["name"] == "travel_insurance"
else ("start_date" if "start_date" in item else "date")
)
def get_yaml_event_end_date_field(item: dict[str, str]) -> str:
"""Event date field name."""
return (
"end_date"
if item["name"] == "travel_insurance"
else ("start_date" if "start_date" in item else "date")
)
def read_events_yaml(data_dir: str, start: date, end: date) -> list[Event]:
"""Read eventes from YAML file."""
events: list[Event] = []
for item in yaml.safe_load(open(os.path.join(data_dir, "events.yaml"))):
duration = (
isodate.parse_duration(item["duration"]) if "duration" in item else None
)
dates = (
dates_from_rrule(item["rrule"], start, end)
if "rrule" in item
else [item[get_yaml_event_date_field(item)]]
)
for dt in dates:
e = Event(
name=item["name"],
date=dt,
end_date=(
dt + duration
if duration
else (
item.get("end_date")
if item["name"] != "travel_insurance"
else None
)
),
title=item.get("title"),
url=item.get("url"),
)
events.append(e)
return events
def find_markets_during_stay(
def find_events_during_stay(
accommodation_events: list[Event], markets: list[Event]
) -> list[Event]:
"""Market events that happen during accommodation stays."""
overlapping_markets = []
for market in markets:
market_date = market.as_date
assert isinstance(market_date, date)
for e in accommodation_events:
start, end = e.as_date, e.end_as_date
assert start and end and all(isinstance(i, date) for i in (start, end))
# Check if the market date is within the accommodation dates.
if e.as_date <= market.as_date <= e.end_as_date:
if start <= market_date <= end:
overlapping_markets.append(market)
break # Breaks the inner loop if overlap is found.
return overlapping_markets
def find_gaps(events: list[Event], min_gap_days: int = 3) -> list[StrDict]:
"""Gaps of at least `min_gap_days` between events in a list of events."""
# Sort events by start date
gaps: list[tuple[date, date]] = []
previous_event_end = None
by_start_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.as_date)
}
by_end_date = {
d: list(on_day)
for d, on_day in itertools.groupby(events, key=lambda e: e.end_as_date)
}
for event in events:
# Use start date for current event
start_date = event.as_date
# If previous event exists, calculate the gap
if previous_event_end:
gap_days = (start_date - previous_event_end).days
if gap_days >= (min_gap_days + 2):
start_end = (
previous_event_end + timedelta(days=1),
start_date - timedelta(days=1),
)
gaps.append(start_end)
# Update previous event end date
end = event.end_as_date
if not previous_event_end or end > previous_event_end:
previous_event_end = end
return [
{
"start": gap_start,
"end": gap_end,
"after": by_start_date[gap_end + timedelta(days=1)],
"before": by_end_date[gap_start - timedelta(days=1)],
}
for gap_start, gap_end in gaps
def hide_markets_while_away(
events: list[Event], accommodation_events: list[Event]
) -> None:
"""Hide markets that happen while away."""
optional = [
e
for e in events
if e.name == "market" or (e.title and "LHG Run Club" in e.title)
]
going = [e for e in events if e.going]
overlapping_markets = find_events_during_stay(
accommodation_events + going, optional
)
for market in overlapping_markets:
events.remove(market)
def busy_event(e: Event) -> bool:
"""Busy."""
if e.name not in {
"event",
"accommodation",
"conference",
"dodainville",
"transport",
"meetup",
"party",
}:
return False
class AgendaData(typing.TypedDict, total=False):
"""Agenda Data."""
if e.title in ("IA UK board meeting", "Mill Road Winter Fair"):
return False
if e.name == "conference" and not e.going:
return False
if not e.title:
return True
if e.title == "LHG Run Club" or "Third Thursday Social" in e.title:
return False
lc_title = e.title.lower()
return "rebels" not in lc_title and "south west data social" not in lc_title
now: datetime
stock_markets: list[str]
rockets: list[thespacedevs.Summary]
gwr_advance_tickets: date | None
data_gather_seconds: float
stock_market_times_seconds: float
timings: list[tuple[str, float]]
events: list[Event]
accommodation_events: list[Event]
gaps: list[StrDict]
sunrise: datetime
sunset: datetime
last_week: date
two_weeks_ago: date
errors: list[tuple[str, Exception]]
async def get_data(
now: datetime, config: flask.config.Config
) -> typing.Mapping[str, str | object]:
def rocket_launch_events(rockets: list[thespacedevs.Summary]) -> list[Event]:
"""Rocket launch events."""
events: list[Event] = []
for launch in rockets:
dt = None
net_precision = launch["net_precision"]
skip = {"Year", "Month", "Quarter", "Fiscal Year"}
if net_precision == "Day":
dt = datetime.strptime(launch["net"], "%Y-%m-%dT%H:%M:%SZ").date()
elif (
net_precision
and net_precision not in skip
and "Year" not in net_precision
and launch["t0_time"]
):
dt = pytz.utc.localize(
datetime.strptime(launch["net"], "%Y-%m-%dT%H:%M:%SZ")
)
if not dt:
continue
rocket_name = (
f'{launch["rocket"]["full_name"]}: '
+ f'{launch["mission_name"] or "[no mission]"}'
)
e = Event(name="rocket", date=dt, title=rocket_name)
events.append(e)
return events
async def get_data(now: datetime, config: flask.config.Config) -> AgendaData:
"""Get data to display on agenda dashboard."""
data_dir = config["DATA_DIR"]
@ -367,28 +180,46 @@ async def get_data(
minus_365 = now - timedelta(days=365)
plus_365 = now + timedelta(days=365)
(
gbpusd,
gwr_advance_tickets,
bank_holiday,
rockets,
backwell_bins,
bristol_bins,
) = await asyncio.gather(
fx.get_gbpusd(config),
gwr.advance_ticket_date(data_dir),
uk_holiday.bank_holiday_list(last_year, next_year, data_dir),
thespacedevs.get_launches(rocket_dir, limit=40),
waste_collection_events(data_dir),
bristol_waste_collection_events(data_dir, today),
t0 = time()
result_list = await asyncio.gather(
time_function("gwr_advance_tickets", gwr.advance_ticket_date, data_dir),
time_function(
"backwell_bins",
n_somerset_waste_collection_events,
data_dir,
config["BACKWELL_POSTCODE"],
config["BACKWELL_UPRN"],
),
time_function(
"bristol_bins",
bristol_waste_collection_events,
data_dir,
today,
config["BRISTOL_UPRN"],
),
)
rockets = thespacedevs.read_cached_launches(rocket_dir)
reply: dict[str, typing.Any] = {
results = {call[0]: call[1] for call in result_list}
errors = [(call[0], call[3]) for call in result_list if call[3]]
gwr_advance_tickets = results["gwr_advance_tickets"]
data_gather_seconds = time() - t0
t0 = time()
stock_market_times = stock_market.open_and_close()
stock_market_times_seconds = time() - t0
reply: AgendaData = {
"now": now,
"gbpusd": gbpusd,
"stock_markets": stock_market.open_and_close(),
"stock_markets": stock_market_times,
"rockets": rockets,
"gwr_advance_tickets": gwr_advance_tickets,
"data_gather_seconds": data_gather_seconds,
"stock_market_times_seconds": stock_market_times_seconds,
"timings": [(call[0], call[2]) for call in result_list],
}
my_data = config["PERSONAL_DATA"]
@ -405,85 +236,43 @@ async def get_data(
if gwr_advance_tickets:
events.append(Event(name="gwr_advance_tickets", date=gwr_advance_tickets))
us_hols = us_holidays(last_year, next_year)
holidays: list[Holiday] = bank_holiday + us_hols
for country in (
"at",
"be",
"br",
"ch",
"cz",
"de",
"dk",
"ee",
"es",
"fi",
"fr",
"gr",
"it",
"ke",
"nl",
"pl",
):
holidays += get_holidays(country, last_year, next_year)
events += get_nyse_holidays(last_year, next_year, us_hols)
us_hols = holidays.us_holidays(last_year, next_year)
events += holidays.get_nyse_holidays(last_year, next_year, us_hols)
accommodation_events = accommodation.get_events(
os.path.join(my_data, "accommodation.yaml")
)
events += combine_holidays(holidays)
events += birthday.get_birthdays(last_year, os.path.join(my_data, "entities.yaml"))
holiday_list = holidays.get_all(last_year, next_year, data_dir)
events += holidays.combine_holidays(holiday_list)
if flask.g.user.is_authenticated:
events += birthday.get_birthdays(
last_year, os.path.join(my_data, "entities.yaml")
)
events += domains.renewal_dates(my_data)
events += accommodation_events
events += travel.all_events(my_data)
events += conference.get_list(os.path.join(my_data, "conferences.yaml"))
events += backwell_bins + bristol_bins
events += read_events_yaml(my_data, last_year, next_year)
for key in "backwell_bins", "bristol_bins":
if results[key]:
events += results[key]
events += events_yaml.read(my_data, last_year, next_year)
events += subscription.get_events(os.path.join(my_data, "subscriptions.yaml"))
events += gandi.get_events(data_dir)
events += economist.publication_dates(last_week, next_year)
events += meetup.get_events(my_data)
events += hn.whoishiring(last_year, next_year)
events += domains.renewal_dates(my_data)
# hide markets that happen while away
markets = [e for e in events if e.name == "market"]
going = [e for e in events if e.going]
overlapping_markets = find_markets_during_stay(
accommodation_events + going, markets
)
for market in overlapping_markets:
events.remove(market)
for launch in rockets:
dt = None
if launch["net_precision"] == "Day":
dt = datetime.strptime(launch["net"], "%Y-%m-%dT00:00:00Z").date()
elif launch["t0_time"]:
dt = pytz.utc.localize(
datetime.strptime(launch["net"], "%Y-%m-%dT%H:%M:%SZ")
)
if not dt:
continue
rocket_name = f'🚀{launch["rocket"]}: {launch["mission_name"] or "[no mission]"}'
e = Event(name="rocket", date=dt, title=rocket_name)
events.append(e)
events += carnival.rio_carnival_events(last_year, next_year)
events += rocket_launch_events(rockets)
events += [Event(name="today", date=today)]
busy_events = [
e
for e in sorted(events, key=lambda e: e.as_date)
if e.as_date > today and e.as_date < next_year and busy_event(e)
if e.as_date > today and e.as_date < next_year and busy.busy_event(e)
]
gaps = find_gaps(busy_events)
gaps = busy.find_gaps(busy_events)
events += [
Event(name="gap", date=gap["start"], end_date=gap["end"]) for gap in gaps
@ -501,9 +290,10 @@ async def get_data(
reply["sunrise"] = sun.sunrise(observer)
reply["sunset"] = sun.sunset(observer)
reply["events"] = events
reply["accommodation_events"] = accommodation_events
reply["last_week"] = last_week
reply["two_weeks_ago"] = two_weeks_ago
reply["fullcalendar_events"] = calendar.build_events(events)
reply["errors"] = errors
return reply

View file

@ -1,4 +1,4 @@
"""Accomodation."""
"""Domain renewal dates."""
import csv
import os

85
agenda/events_yaml.py Normal file
View file

@ -0,0 +1,85 @@
"""Read events from YAML."""
import os
import typing
from datetime import date, datetime
import dateutil.rrule
import isodate # type: ignore
import yaml
from . import uk_tz
from .types import Event
def midnight(d: date) -> datetime:
"""Convert from date to midnight on that day."""
return datetime.combine(d, datetime.min.time())
def dates_from_rrule(
rrule: str, start: date, end: date
) -> typing.Sequence[datetime | date]:
"""Generate events from an RRULE between start_date and end_date."""
all_day = not any(param in rrule for param in ["BYHOUR", "BYMINUTE", "BYSECOND"])
return [
i.date() if all_day else uk_tz.localize(i)
for i in dateutil.rrule.rrulestr(rrule, dtstart=midnight(start)).between(
midnight(start), midnight(end)
)
]
def get_yaml_event_date_field(item: dict[str, str]) -> str:
"""Event date field name."""
return (
"end_date"
if item["name"] == "travel_insurance"
else ("start_date" if "start_date" in item else "date")
)
def get_yaml_event_end_date_field(item: dict[str, str]) -> str:
"""Event date field name."""
return (
"end_date"
if item["name"] == "travel_insurance"
else ("start_date" if "start_date" in item else "date")
)
def read(
data_dir: str, start: date, end: date, skip_trips: bool = False
) -> list[Event]:
"""Read eventes from YAML file."""
events: list[Event] = []
for item in yaml.safe_load(open(os.path.join(data_dir, "events.yaml"))):
if "trip" in item and skip_trips:
continue
duration = (
isodate.parse_duration(item["duration"]) if "duration" in item else None
)
dates = (
dates_from_rrule(item["rrule"], start, end)
if "rrule" in item
else [item[get_yaml_event_date_field(item)]]
)
for dt in dates:
e = Event(
name=item["name"],
date=dt,
end_date=(
dt + duration
if duration
else (
item.get("end_date")
if item["name"] != "travel_insurance"
else None
)
),
title=item.get("title"),
url=item.get("url"),
)
events.append(e)
return events

View file

@ -42,3 +42,72 @@ async def get_gbpusd(config: flask.config.Config) -> Decimal:
data = json.loads(r.text, parse_float=Decimal)
return typing.cast(Decimal, 1 / data["quotes"]["USDGBP"])
def read_cached_rates(
filename: str | None, currencies: list[str]
) -> dict[str, Decimal]:
"""Read FX rates from cache."""
if filename is None:
return {}
with open(filename) as file:
data = json.load(file, parse_float=Decimal)
return {
cur: Decimal(data["quotes"][f"GBP{cur}"])
for cur in currencies
if f"GBP{cur}" in data["quotes"]
}
def get_rates(config: flask.config.Config) -> dict[str, Decimal]:
"""Get current values of exchange rates for a list of currencies against GBP."""
currencies = config["CURRENCIES"]
access_key = config["EXCHANGERATE_ACCESS_KEY"]
data_dir = config["DATA_DIR"]
now = datetime.now()
now_str = now.strftime("%Y-%m-%d_%H:%M")
fx_dir = os.path.join(data_dir, "fx")
os.makedirs(fx_dir, exist_ok=True) # Ensure the directory exists
currency_string = ",".join(sorted(currencies))
file_suffix = f"{currency_string}_to_GBP.json"
existing_data = os.listdir(fx_dir)
existing_files = [f for f in existing_data if f.endswith(".json")]
full_path: str | None = None
if existing_files:
recent_filename = max(existing_files)
recent = datetime.strptime(recent_filename[:16], "%Y-%m-%d_%H:%M")
delta = now - recent
full_path = os.path.join(fx_dir, recent_filename)
if recent_filename.endswith(file_suffix) and delta < timedelta(hours=12):
return read_cached_rates(full_path, currencies)
url = "http://api.exchangerate.host/live"
params = {"currencies": currency_string, "source": "GBP", "access_key": access_key}
filename = f"{now_str}_{file_suffix}"
try:
with httpx.Client() as client:
response = client.get(url, params=params)
except httpx.ConnectError:
return read_cached_rates(full_path, currencies)
try:
data = json.loads(response.text, parse_float=Decimal)
except json.decoder.JSONDecodeError:
return read_cached_rates(full_path, currencies)
with open(os.path.join(fx_dir, filename), "w") as file:
file.write(response.text)
return {
cur: Decimal(data["quotes"][f"GBP{cur}"])
for cur in currencies
if f"GBP{cur}" in data["quotes"]
}

27
agenda/gandi.py Normal file
View file

@ -0,0 +1,27 @@
"""Gandi domain renewal dates."""
import os
from .types import Event
from datetime import datetime
import json
def get_events(data_dir: str) -> list[Event]:
"""Get subscription renewal dates."""
filename = os.path.join(data_dir, "gandi_domains.json")
with open(filename) as f:
items = json.load(f)
assert isinstance(items, list)
assert all(item["fqdn"] and item["dates"]["registry_ends_at"] for item in items)
return [
Event(
date=datetime.fromisoformat(item["dates"]["registry_ends_at"]).date(),
name="domain",
title=item["fqdn"] + " renewal",
)
for item in items
]

107
agenda/geomob.py Normal file
View file

@ -0,0 +1,107 @@
"""Geomob events."""
import os
from dataclasses import dataclass
from datetime import date, datetime
from typing import List
import dateutil.parser
import flask
import lxml.html
import requests
import agenda.mail
import agenda.utils
@dataclass(frozen=True)
class GeomobEvent:
"""Geomob event."""
date: date
href: str
hashtag: str
def extract_events(
tree: lxml.html.HtmlElement,
) -> List[GeomobEvent]:
"""Extract upcoming events from the HTML content."""
events = []
for event in tree.xpath('//ol[@class="event-list"]/li/a'):
date_str, _, hashtag = event.text_content().strip().rpartition(" ")
events.append(
GeomobEvent(
date=dateutil.parser.parse(date_str).date(),
href=event.get("href"),
hashtag=hashtag,
)
)
return events
def find_new_events(
prev: list[GeomobEvent], cur: list[GeomobEvent]
) -> list[GeomobEvent]:
"""Find new events that appear in cur but not in prev."""
return list(set(cur) - set(prev))
def geomob_email(new_events: list[GeomobEvent], base_url: str) -> tuple[str, str]:
"""Generate email subject and body for new events.
Args:
new_events (List[Event]): List of new events.
base_url (str): The base URL of the website.
Returns:
tuple[str, str]: Email subject and body.
"""
assert new_events
subject = f"{len(new_events)} New Geomob Event(s) Announced"
body_lines = ["Hello,\n", "Here are the new Geomob events:\n"]
for event in new_events:
event_details = (
f"Date: {event.date}\n"
f"URL: {base_url + event.href}\n"
f"Hashtag: {event.hashtag}\n"
)
body_lines.append(event_details)
body_lines.append("-" * 40)
body = "\n".join(body_lines)
return (subject, body)
def get_cached_upcoming_events_list(geomob_dir: str) -> list[GeomobEvent]:
"""Get known geomob events."""
filename = agenda.utils.get_most_recent_file(geomob_dir, "html")
return extract_events(lxml.html.parse(filename).getroot()) if filename else []
def update(config: flask.config.Config) -> None:
"""Get upcoming Geomob events and report new ones."""
geomob_dir = os.path.join(config["DATA_DIR"], "geomob")
prev_events = get_cached_upcoming_events_list(geomob_dir)
r = requests.get("https://thegeomob.com/")
cur_events = extract_events(lxml.html.fromstring(r.content))
if cur_events == prev_events:
return # no change
now = datetime.now()
new_filename = os.path.join(geomob_dir, now.strftime("%Y-%m-%d_%H:%M:%S.html"))
open(new_filename, "w").write(r.text)
new_events = list(set(cur_events) - set(prev_events))
if not new_events:
return
base_url = "https://thegeomob.com/"
subject, body = geomob_email(new_events, base_url)
agenda.mail.send_mail(config, subject, body)

159
agenda/holidays.py Normal file
View file

@ -0,0 +1,159 @@
"""Holidays."""
import collections
from datetime import date, timedelta
import agenda.uk_holiday
import holidays
from .types import Event, Holiday
def us_holidays(start_date: date, end_date: date) -> list[Holiday]:
"""Get US holidays."""
found: list[Holiday] = []
for year in range(start_date.year, end_date.year + 1):
hols = holidays.country_holidays("US", years=year, language="en")
found += [
Holiday(date=hol_date, name=title, country="us")
for hol_date, title in hols.items()
if start_date < hol_date < end_date
]
extra = []
for h in found:
if h.name != "Thanksgiving":
continue
extra += [
Holiday(date=h.date + timedelta(days=1), name="Black Friday", country="us"),
Holiday(date=h.date + timedelta(days=4), name="Cyber Monday", country="us"),
]
return found + extra
def get_nyse_holidays(
start_date: date, end_date: date, us_hols: list[Holiday]
) -> list[Event]:
"""NYSE holidays."""
known_us_hols = {(h.date, h.name) for h in us_hols}
found: list[Event] = []
rename = {"Thanksgiving Day": "Thanksgiving"}
for year in range(start_date.year, end_date.year + 1):
hols = holidays.financial_holidays("NYSE", years=year)
found += [
Event(
name="holiday",
date=hol_date,
title=rename.get(title, title),
)
for hol_date, title in hols.items()
if start_date <= hol_date <= end_date
]
found = [hol for hol in found if (hol.date, hol.title) not in known_us_hols]
for hol in found:
assert hol.title
hol.title += " (NYSE)"
return found
def get_holidays(country: str, start_date: date, end_date: date) -> list[Holiday]:
"""Get holidays."""
found: list[Holiday] = []
uc_country = country.upper()
holiday_country = getattr(holidays, uc_country)
default_language = holiday_country.default_language
for year in range(start_date.year, end_date.year + 1):
en_hols = holidays.country_holidays(uc_country, years=year, language="en_US")
local_lang = holidays.country_holidays(
uc_country, years=year, language=default_language
)
found += [
Holiday(
date=hol_date,
name=title,
local_name=local_lang[hol_date],
country=country.lower(),
)
for hol_date, title in en_hols.items()
if start_date <= hol_date <= end_date
]
return found
def combine_holidays(holidays: list[Holiday]) -> list[Event]:
"""Combine UK and US holidays with the same date and title."""
all_countries = {h.country for h in holidays}
standard_name = {
(1, 1): "New Year's Day",
(1, 6): "Epiphany",
(5, 1): "Labour Day",
(8, 15): "Assumption Day",
(12, 8): "Immaculate conception",
(12, 25): "Christmas Day",
(12, 26): "Boxing Day",
}
combined: collections.defaultdict[
tuple[date, str], set[str]
] = collections.defaultdict(set)
for h in holidays:
assert isinstance(h.name, str) and isinstance(h.date, date)
event_key = (h.date, standard_name.get((h.date.month, h.date.day), h.name))
combined[event_key].add(h.country)
events: list[Event] = []
for (d, name), countries in combined.items():
if len(countries) == len(all_countries):
country_list = ""
elif len(countries) < len(all_countries) / 2:
country_list = ", ".join(sorted(country.upper() for country in countries))
else:
country_list = "not " + ", ".join(
sorted(country.upper() for country in all_countries - set(countries))
)
e = Event(
name="holiday",
date=d,
title=f"{name} ({country_list})" if country_list else name,
)
events.append(e)
return events
def get_all(last_year: date, next_year: date, data_dir: str) -> list[Holiday]:
"""Get holidays for various countries and return as a list."""
us_hols = us_holidays(last_year, next_year)
bank_holidays = agenda.uk_holiday.bank_holiday_list(last_year, next_year, data_dir)
holiday_list: list[Holiday] = bank_holidays + us_hols
for country in (
"at",
"be",
"br",
"ch",
"cz",
"de",
"dk",
"ee",
"es",
"fi",
"fr",
"gr",
"it",
"ke",
"nl",
"pl",
):
holiday_list += get_holidays(country, last_year, next_year)
return holiday_list

28
agenda/mail.py Normal file
View file

@ -0,0 +1,28 @@
"""Send e-mail."""
import smtplib
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import flask
def send_mail(config: flask.config.Config, subject: str, body: str) -> None:
"""Send an e-mail."""
msg = EmailMessage()
msg["Subject"] = subject
msg["To"] = f"{config['NAME']} <{config['MAIL_TO']}>"
msg["From"] = f"{config['NAME']} <{config['MAIL_FROM']}>"
msg["Date"] = formatdate()
msg["Message-ID"] = make_msgid()
# Add extra mail headers
for header, value in config["MAIL_HEADERS"]:
msg[header] = value
msg.set_content(body)
s = smtplib.SMTP(config["SMTP_HOST"])
s.sendmail(config["MAIL_TO"], [config["MAIL_TO"]], msg.as_string())
s.quit()

View file

@ -21,7 +21,7 @@ def get_events(data_dir: str) -> list[Event]:
date=start,
end_date=end,
name="meetup",
title="👥" + item_event["title"],
title=item_event["title"],
url=item_event["eventUrl"],
)
events.append(e)

View file

@ -0,0 +1,91 @@
"""Waste collection schedules."""
import os
import re
from collections import defaultdict
from datetime import date, datetime, time, timedelta
import httpx
import lxml.html
from . import uk_time
from .types import Event
from .utils import make_waste_dir
ttl_hours = 12
async def get_html(data_dir: str, postcode: str, uprn: str) -> str:
"""Get waste schedule."""
now = datetime.now()
waste_dir = os.path.join(data_dir, "waste")
make_waste_dir(data_dir)
existing_data = os.listdir(waste_dir)
existing = [f for f in existing_data if f.endswith(".html")]
if existing:
recent_filename = max(existing)
recent = datetime.strptime(recent_filename, "%Y-%m-%d_%H:%M.html")
delta = now - recent
if existing and delta < timedelta(hours=ttl_hours):
return open(os.path.join(waste_dir, recent_filename)).read()
now_str = now.strftime("%Y-%m-%d_%H:%M")
filename = f"{waste_dir}/{now_str}.html"
forms_base_url = "https://forms.n-somerset.gov.uk"
url = "https://forms.n-somerset.gov.uk/Waste/CollectionSchedule"
async with httpx.AsyncClient() as client:
r = await client.post(
url,
data={
"PreviousHouse": "",
"PreviousPostcode": "-",
"Postcode": postcode,
"SelectedUprn": uprn,
},
)
form_post_html = r.text
pattern = r'<h2>Object moved to <a href="([^"]*)">here<\/a>\.<\/h2>'
m = re.search(pattern, form_post_html)
if m:
r = await client.get(forms_base_url + m.group(1))
html = r.text
open(filename, "w").write(html)
return html
def parse_waste_schedule_date(day_and_month: str) -> date:
"""Parse waste schedule date."""
today = date.today()
fmt = "%A %d %B %Y"
d = datetime.strptime(f"{day_and_month} {today.year}", fmt).date()
if d < today:
d = datetime.strptime(f"{day_and_month} {today.year + 1}", fmt).date()
return d
def parse(root: lxml.html.HtmlElement) -> list[Event]:
"""Parse waste schedule."""
tbody = root.find(".//table/tbody")
assert tbody is not None
by_date = defaultdict(list)
for e_service, e_next_date, e_following in tbody:
assert e_service.text and e_next_date.text and e_following.text
service = e_service.text
next_date = parse_waste_schedule_date(e_next_date.text)
following_date = parse_waste_schedule_date(e_following.text)
by_date[next_date].append(service)
by_date[following_date].append(service)
return [
Event(
name="waste_schedule",
date=uk_time(d, time(6, 30)),
title="Backwell: " + ", ".join(services),
)
for d, services in by_date.items()
]

View file

@ -3,26 +3,14 @@
from datetime import timedelta, timezone
import dateutil.tz
import exchange_calendars
import pandas
import exchange_calendars # type: ignore
import pandas # type: ignore
from . import utils
here = dateutil.tz.tzlocal()
def timedelta_display(delta: timedelta) -> str:
"""Format timedelta as a human readable string."""
total_seconds = int(delta.total_seconds())
days, remainder = divmod(total_seconds, 24 * 60 * 60)
hours, remainder = divmod(remainder, 60 * 60)
mins, secs = divmod(remainder, 60)
return " ".join(
f"{v:>3} {label}"
for v, label in ((days, "days"), (hours, "hrs"), (mins, "mins"))
if v
)
def open_and_close() -> list[str]:
"""Stock markets open and close times."""
# The trading calendars code is slow, maybe there is a faster way to do this
@ -40,11 +28,11 @@ def open_and_close() -> list[str]:
if cal.is_open_on_minute(now_local):
next_close = cal.next_close(now).tz_convert(here)
next_close = next_close.replace(minute=round(next_close.minute, -1))
delta_close = timedelta_display(next_close - now_local)
delta_close = utils.timedelta_display(next_close - now_local)
prev_open = cal.previous_open(now).tz_convert(here)
prev_open = prev_open.replace(minute=round(prev_open.minute, -1))
delta_open = timedelta_display(now_local - prev_open)
delta_open = utils.timedelta_display(now_local - prev_open)
msg = (
f"{label:>6} market opened {delta_open} ago, "
@ -54,7 +42,7 @@ def open_and_close() -> list[str]:
ts = cal.next_open(now)
ts = ts.replace(minute=round(ts.minute, -1))
ts = ts.tz_convert(here)
delta = timedelta_display(ts - now_local)
delta = utils.timedelta_display(ts - now_local)
msg = f"{label:>6} market opens in {delta}" + (
f" ({ts:%H:%M})" if (ts - now_local) < timedelta(days=1) else ""
)

View file

@ -3,7 +3,7 @@
import typing
from datetime import datetime
import ephem
import ephem # type: ignore
def bristol() -> ephem.Observer:

View file

@ -5,33 +5,41 @@ import os
import typing
from datetime import datetime
import httpx
import requests
from .types import StrDict
from .utils import filename_timestamp, get_most_recent_file
Launch = dict[str, typing.Any]
Summary = dict[str, typing.Any]
ttl = 60 * 60 * 2 # two hours
async def next_launch_api(rocket_dir: str, limit: int = 200) -> list[Launch]:
LIMIT = 500
def next_launch_api_data(rocket_dir: str, limit: int = LIMIT) -> StrDict | None:
"""Get the next upcoming launches from the API."""
now = datetime.now()
filename = os.path.join(rocket_dir, now.strftime("%Y-%m-%d_%H:%M:%S.json"))
url = "https://ll.thespacedevs.com/2.2.0/launch/upcoming/"
params: dict[str, str | int] = {"limit": limit}
async with httpx.AsyncClient() as client:
r = await client.get(url, params=params)
open(filename, "w").write(r.text)
data = r.json()
return [summarize_launch(launch) for launch in data["results"]]
def filename_timestamp(filename: str) -> tuple[datetime, str] | None:
"""Get datetime from filename."""
r = requests.get(url, params=params)
try:
ts = datetime.strptime(filename, "%Y-%m-%d_%H:%M:%S.json")
except ValueError:
data: StrDict = r.json()
except requests.exceptions.JSONDecodeError:
return None
return (ts, filename)
open(filename, "w").write(r.text)
return data
def next_launch_api(rocket_dir: str, limit: int = LIMIT) -> list[Summary] | None:
"""Get the next upcoming launches from the API."""
data = next_launch_api_data(rocket_dir, limit)
if not data:
return None
return [summarize_launch(launch) for launch in data["results"]]
def format_time(time_str: str, net_precision: str) -> tuple[str, str | None]:
@ -116,6 +124,7 @@ def summarize_launch(launch: Launch) -> Summary:
return {
"name": launch.get("name"),
"slug": launch["slug"],
"status": launch.get("status"),
"net": launch.get("net"),
"net_precision": net_precision,
@ -126,7 +135,7 @@ def summarize_launch(launch: Launch) -> Summary:
"launch_provider": launch_provider,
"launch_provider_abbrev": launch_provider_abbrev,
"launch_provider_type": get_nested(launch, ["launch_service_provider", "type"]),
"rocket": launch["rocket"]["configuration"]["full_name"],
"rocket": launch["rocket"]["configuration"],
"mission": launch.get("mission"),
"mission_name": get_nested(launch, ["mission", "name"]),
"pad_name": launch["pad"]["name"],
@ -137,18 +146,34 @@ def summarize_launch(launch: Launch) -> Summary:
}
async def get_launches(rocket_dir: str, limit: int = 200) -> list[Summary]:
def load_cached_launches(rocket_dir: str) -> StrDict | None:
"""Read the most recent cache of launches."""
filename = get_most_recent_file(rocket_dir, "json")
return typing.cast(StrDict, json.load(open(filename))) if filename else None
def read_cached_launches(rocket_dir: str) -> list[Summary]:
"""Read cached launches."""
data = load_cached_launches(rocket_dir)
return [summarize_launch(launch) for launch in data["results"]] if data else []
def get_launches(
rocket_dir: str, limit: int = LIMIT, refresh: bool = False
) -> list[Summary] | None:
"""Get rocket launches with caching."""
now = datetime.now()
existing = [x for x in (filename_timestamp(f) for f in os.listdir(rocket_dir)) if x]
existing = [
x for x in (filename_timestamp(f, "json") for f in os.listdir(rocket_dir)) if x
]
existing.sort(reverse=True)
if not existing or (now - existing[0][0]).seconds > 3600: # one hour
if refresh or not existing or (now - existing[0][0]).seconds > ttl:
try:
return await next_launch_api(rocket_dir, limit=limit)
except httpx.ReadTimeout:
pass
return next_launch_api(rocket_dir, limit=limit)
except Exception:
pass # fallback to cached version
f = existing[0][1]

View file

@ -1,36 +1,78 @@
"""Travel."""
import decimal
import json
import os
import typing
import flask
import yaml
from geopy.distance import geodesic # type: ignore
from .types import Event
from .types import Event, StrDict
Leg = dict[str, str]
TravelList = list[dict[str, typing.Any]]
RouteDistances = dict[tuple[str, str], float]
def coords(airport: StrDict) -> tuple[float, float]:
"""Longitude / Latitude as coordinate tuples."""
# return (airport["longitude"], airport["latitude"])
return (airport["latitude"], airport["longitude"])
def flight_distance(f: StrDict) -> float:
"""Distance of flight."""
return float(geodesic(coords(f["from_airport"]), coords(f["to_airport"])).km)
def route_distances_as_json(route_distances: RouteDistances) -> str:
"""Format route distances as JSON string."""
return (
"[\n"
+ ",\n".join(
" " + json.dumps([s1, s2, dist])
for (s1, s2), dist in route_distances.items()
)
+ "\n]"
)
def parse_yaml(travel_type: str, data_dir: str) -> TravelList:
"""Parse flights YAML and return list of travel."""
filepath = os.path.join(data_dir, travel_type + ".yaml")
return typing.cast(TravelList, yaml.safe_load(open(filepath)))
items: TravelList = yaml.safe_load(open(filepath))
if not all(isinstance(item, dict) for item in items):
return items
for item in items:
price = item.get("price")
if price:
item["price"] = decimal.Decimal(price)
return items
def get_flights(data_dir: str) -> list[Event]:
"""Get travel events."""
return [
Event(
date=item["depart"],
end_date=item.get("arrive"),
name="transport",
title=f'✈️ {item["from"]} to {item["to"]} ({flight_number(item)})',
url=item.get("url"),
)
for item in parse_yaml("flights", data_dir)
if item["depart"].date()
]
bookings = parse_yaml("flights", data_dir)
events = []
for booking in bookings:
for item in booking["flights"]:
if not item["depart"].date():
continue
e = Event(
date=item["depart"],
end_date=item.get("arrive"),
name="transport",
title=f'✈️ {item["from"]} to {item["to"]} ({flight_number(item)})',
url=(item.get("url") if flask.g.user.is_authenticated else None),
)
events.append(e)
return events
def get_trains(data_dir: str) -> list[Event]:
@ -43,7 +85,7 @@ def get_trains(data_dir: str) -> list[Event]:
end_date=leg["arrive"],
name="transport",
title=f'🚆 {leg["from"]} to {leg["to"]}',
url=item.get("url"),
url=(item.get("url") if flask.g.user.is_authenticated else None),
)
for leg in item["legs"]
]
@ -62,3 +104,43 @@ def flight_number(flight: Leg) -> str:
def all_events(data_dir: str) -> list[Event]:
"""Get all flights and rail journeys."""
return get_trains(data_dir) + get_flights(data_dir)
def train_leg_distance(geojson_data: StrDict) -> float:
"""Calculate the total length of a LineString in kilometers from GeoJSON data."""
# Extract coordinates
first_object = geojson_data["features"][0]["geometry"]
assert first_object["type"] in ("LineString", "MultiLineString")
if first_object["type"] == "LineString":
coord_list = [first_object["coordinates"]]
else:
first_object["type"] == "MultiLineString"
coord_list = first_object["coordinates"]
total_length_km = 0.0
for coordinates in coord_list:
total_length_km += sum(
float(geodesic(coordinates[i], coordinates[i + 1]).km)
for i in range(len(coordinates) - 1)
)
return total_length_km
def load_route_distances(data_dir: str) -> RouteDistances:
"""Load cache of route distances."""
route_distances: RouteDistances = {}
with open(os.path.join(data_dir, "route_distances.json")) as f:
for s1, s2, dist in json.load(f):
route_distances[(s1, s2)] = dist
return route_distances
def add_leg_route_distance(leg: StrDict, route_distances: RouteDistances) -> None:
s1, s2 = sorted([leg["from"], leg["to"]])
dist = route_distances.get((s1, s2))
if dist:
leg["distance"] = dist

434
agenda/trip.py Normal file
View file

@ -0,0 +1,434 @@
"""Trips."""
import decimal
import os
import typing
from collections import defaultdict
from datetime import date, datetime, time
from zoneinfo import ZoneInfo
import flask
import yaml
from agenda import travel
from agenda.types import StrDict, Trip
class UnknownStation(Exception):
"""Unknown station."""
pass
def load_travel(travel_type: str, plural: str, data_dir: str) -> list[StrDict]:
"""Read flight and train journeys."""
items = travel.parse_yaml(plural, data_dir)
for item in items:
item["type"] = travel_type
return items
def process_train_leg(
leg: StrDict,
by_name: StrDict,
route_distances: travel.RouteDistances | None,
) -> None:
"""Process train leg."""
assert leg["from"] in by_name and leg["to"] in by_name
leg["from_station"], leg["to_station"] = by_name[leg["from"]], by_name[leg["to"]]
if route_distances:
travel.add_leg_route_distance(leg, route_distances)
def load_trains(
data_dir: str, route_distances: travel.RouteDistances | None = None
) -> list[StrDict]:
"""Load trains."""
trains = load_travel("train", "trains", data_dir)
stations = travel.parse_yaml("stations", data_dir)
by_name = {station["name"]: station for station in stations}
for train in trains:
if train["from"] not in by_name:
raise UnknownStation(train["from"])
if train["to"] not in by_name:
raise UnknownStation(train["to"])
train["from_station"] = by_name[train["from"]]
train["to_station"] = by_name[train["to"]]
for leg in train["legs"]:
process_train_leg(leg, by_name=by_name, route_distances=route_distances)
if all("distance" in leg for leg in train["legs"]):
train["distance"] = sum(leg["distance"] for leg in train["legs"])
return trains
def load_ferries(
data_dir: str, route_distances: travel.RouteDistances | None = None
) -> list[StrDict]:
"""Load ferries."""
ferries = load_travel("ferry", "ferries", data_dir)
terminals = travel.parse_yaml("ferry_terminals", data_dir)
by_name = {terminal["name"]: terminal for terminal in terminals}
for item in ferries:
assert item["from"] in by_name and item["to"] in by_name
from_terminal, to_terminal = by_name[item["from"]], by_name[item["to"]]
item["from_terminal"] = from_terminal
item["to_terminal"] = to_terminal
if route_distances:
travel.add_leg_route_distance(item, route_distances)
geojson = from_terminal["routes"].get(item["to"])
if geojson:
item["geojson_filename"] = geojson
return ferries
def depart_datetime(item: StrDict) -> datetime:
depart = item["depart"]
if isinstance(depart, datetime):
return depart
return datetime.combine(depart, time.min).replace(tzinfo=ZoneInfo("UTC"))
def process_flight(
flight: StrDict, iata: dict[str, str], airports: list[StrDict]
) -> None:
"""Add airport detail, airline name and distance to flight."""
if flight["from"] in airports:
flight["from_airport"] = airports[flight["from"]]
if flight["to"] in airports:
flight["to_airport"] = airports[flight["to"]]
if "airline" in flight:
flight["airline_name"] = iata.get(flight["airline"], "[unknown]")
flight["distance"] = travel.flight_distance(flight)
def load_flight_bookings(data_dir: str) -> list[StrDict]:
"""Load flight bookings."""
bookings = load_travel("flight", "flights", data_dir)
airlines = yaml.safe_load(open(os.path.join(data_dir, "airlines.yaml")))
iata = {a["iata"]: a["name"] for a in airlines}
airports = travel.parse_yaml("airports", data_dir)
for booking in bookings:
for flight in booking["flights"]:
process_flight(flight, iata, airports)
return bookings
def load_flights(flight_bookings: list[StrDict]) -> list[StrDict]:
"""Load flights."""
flights = []
for booking in flight_bookings:
for flight in booking["flights"]:
for f in "type", "trip", "booking_reference", "price", "currency":
if f in booking:
flight[f] = booking[f]
flights.append(flight)
return flights
def build_trip_list(
data_dir: str | None = None,
route_distances: travel.RouteDistances | None = None,
) -> list[Trip]:
"""Generate list of trips."""
trips: dict[date, Trip] = {}
if data_dir is None:
data_dir = flask.current_app.config["PERSONAL_DATA"]
yaml_trip_list = travel.parse_yaml("trips", data_dir)
yaml_trip_lookup = {item["trip"]: item for item in yaml_trip_list}
flight_bookings = load_flight_bookings(data_dir)
travel_items = sorted(
load_flights(flight_bookings)
+ load_trains(data_dir, route_distances=route_distances)
+ load_ferries(data_dir, route_distances=route_distances),
key=depart_datetime,
)
data = {
"flight_bookings": flight_bookings,
"travel": travel_items,
"accommodation": travel.parse_yaml("accommodation", data_dir),
"conferences": travel.parse_yaml("conferences", data_dir),
"events": travel.parse_yaml("events", data_dir),
}
for item in data["accommodation"]:
price = item.get("price")
if price:
item["price"] = decimal.Decimal(price)
for key, item_list in data.items():
assert isinstance(item_list, list)
for item in item_list:
if not (start := item.get("trip")):
continue
if start not in trips:
from_yaml = yaml_trip_lookup.get(start, {})
trips[start] = Trip(
start=start, **{k: v for k, v in from_yaml.items() if k != "trip"}
)
getattr(trips[start], key).append(item)
return [trip for _, trip in sorted(trips.items())]
def add_coordinates_for_unbooked_flights(
routes: list[StrDict], coordinates: list[StrDict]
) -> None:
"""Add coordinates for flights that haven't been booked yet."""
if not (
any(route["type"] == "unbooked_flight" for route in routes)
and not any(pin["type"] == "airport" for pin in coordinates)
):
return
data_dir = flask.current_app.config["PERSONAL_DATA"]
airports = typing.cast(dict[str, StrDict], travel.parse_yaml("airports", data_dir))
lhr = airports["LHR"]
coordinates.append(
{
"name": lhr["name"],
"type": "airport",
"latitude": lhr["latitude"],
"longitude": lhr["longitude"],
}
)
def collect_trip_coordinates(trip: Trip) -> list[StrDict]:
"""Extract and deduplicate airport and station coordinates from trip."""
stations = {}
station_list = []
airports = {}
ferry_terminals = {}
for t in trip.travel:
if t["type"] == "train":
station_list += [t["from_station"], t["to_station"]]
for leg in t["legs"]:
station_list.append(leg["from_station"])
station_list.append(leg["to_station"])
elif t["type"] == "flight":
for field in "from_airport", "to_airport":
if field in t:
airports[t[field]["iata"]] = t[field]
else:
assert t["type"] == "ferry"
for field in "from_terminal", "to_terminal":
terminal = t[field]
ferry_terminals[terminal["name"]] = terminal
for s in station_list:
if s["uic"] in stations:
continue
stations[s["uic"]] = s
coords = []
src = [
("accommodation", trip.accommodation),
("conference", trip.conferences),
("event", trip.events),
]
for coord_type, item_list in src:
coords += [
{
"name": item["name"],
"type": coord_type,
"latitude": item["latitude"],
"longitude": item["longitude"],
}
for item in item_list
if "latitude" in item and "longitude" in item
]
locations = [
("station", stations),
("airport", airports),
("ferry_terminal", ferry_terminals),
]
for coord_type, coord_dict in locations:
coords += [
{
"name": s["name"],
"type": coord_type,
"latitude": s["latitude"],
"longitude": s["longitude"],
}
for s in coord_dict.values()
]
return coords
def latlon_tuple(stop: StrDict) -> tuple[float, float]:
"""Given a transport stop return the lat/lon as a tuple."""
return (stop["latitude"], stop["longitude"])
def read_geojson(data_dir: str, filename: str) -> str:
"""Read GeoJSON from file."""
return open(os.path.join(data_dir, filename + ".geojson")).read()
def get_trip_routes(trip: Trip) -> list[StrDict]:
"""Get routes for given trip to show on map."""
routes: list[StrDict] = []
seen_geojson = set()
for t in trip.travel:
if t["type"] == "ferry":
ferry_from, ferry_to = t["from_terminal"], t["to_terminal"]
key = "_".join(["ferry"] + sorted([ferry_from["name"], ferry_to["name"]]))
filename = os.path.join("ferry_routes", t["geojson_filename"])
routes.append(
{
"type": "train",
"key": key,
"geojson_filename": filename,
}
)
continue
if t["type"] == "flight":
if "from_airport" not in t or "to_airport" not in t:
continue
fly_from, fly_to = t["from_airport"], t["to_airport"]
key = "_".join(["flight"] + sorted([fly_from["iata"], fly_to["iata"]]))
routes.append(
{
"type": "flight",
"key": key,
"from": latlon_tuple(fly_from),
"to": latlon_tuple(fly_to),
}
)
continue
assert t["type"] == "train"
for leg in t["legs"]:
train_from, train_to = leg["from_station"], leg["to_station"]
geojson_filename = train_from.get("routes", {}).get(train_to["uic"])
key = "_".join(["train"] + sorted([train_from["name"], train_to["name"]]))
if not geojson_filename:
routes.append(
{
"type": "train",
"key": key,
"from": latlon_tuple(train_from),
"to": latlon_tuple(train_to),
}
)
continue
if geojson_filename in seen_geojson:
continue
seen_geojson.add(geojson_filename)
routes.append(
{
"type": "train",
"key": key,
"geojson_filename": os.path.join("train_routes", geojson_filename),
}
)
if routes:
return routes
lhr = (51.4775, -0.461389)
return [
{
"type": "unbooked_flight",
"key": f'LHR_{item["location"]}_{item["country"]}',
"from": lhr,
"to": latlon_tuple(item),
}
for item in trip.conferences
if "latitude" in item
and "longitude" in item
and item["country"] not in ("gb", "be") # not flying to Belgium
]
def get_coordinates_and_routes(
trip_list: list[Trip], data_dir: str | None = None
) -> tuple[list[StrDict], list[StrDict]]:
if data_dir is None:
data_dir = flask.current_app.config["PERSONAL_DATA"]
coordinates = []
seen_coordinates: set[tuple[str, str]] = set()
routes = []
seen_routes: set[str] = set()
for trip in trip_list:
for stop in collect_trip_coordinates(trip):
key = (stop["type"], stop["name"])
if key in seen_coordinates:
continue
coordinates.append(stop)
seen_coordinates.add(key)
for route in get_trip_routes(trip):
if route["key"] in seen_routes:
continue
routes.append(route)
seen_routes.add(route["key"])
for route in routes:
if "geojson_filename" in route:
route["geojson"] = read_geojson(data_dir, route.pop("geojson_filename"))
return (coordinates, routes)
def calculate_yearly_stats(trips: list[Trip]) -> dict[int, StrDict]:
"""Calculate total distance and distance by transport type grouped by year."""
yearly_stats: defaultdict[int, StrDict] = defaultdict(dict)
for trip in trips:
year = trip.start.year
dist = trip.total_distance()
yearly_stats[year].setdefault("count", 0)
yearly_stats[year]["count"] += 1
for c in trip.conferences:
yearly_stats[c["start"].year].setdefault("conferences", 0)
yearly_stats[c["start"].year]["conferences"] += 1
if dist:
yearly_stats[year]["total_distance"] = (
yearly_stats[year].get("total_distance", 0) + trip.total_distance()
)
for transport_type, distance in trip.distances_by_transport_type():
yearly_stats[year].setdefault("distances_by_transport_type", {})
yearly_stats[year]["distances_by_transport_type"][transport_type] = (
yearly_stats[year]["distances_by_transport_type"].get(transport_type, 0)
+ distance
)
for country in trip.countries:
if country.alpha_2 == "GB":
continue
yearly_stats[year].setdefault("countries", set())
yearly_stats[year]["countries"].add(country)
for leg in trip.travel:
if leg["type"] == "flight":
yearly_stats[year].setdefault("flight_count", 0)
yearly_stats[year]["flight_count"] += 1
if leg["type"] == "train":
yearly_stats[year].setdefault("train_count", 0)
yearly_stats[year]["train_count"] += 1
return dict(yearly_stats)

View file

@ -1,25 +1,387 @@
"""Types."""
import dataclasses
import collections
import datetime
import typing
from collections import defaultdict
from dataclasses import dataclass, field
import emoji
from pycountry.db import Country
import agenda
from agenda import format_list_with_ampersand
from . import utils
StrDict = dict[str, typing.Any]
DateOrDateTime = datetime.datetime | datetime.date
@dataclasses.dataclass
@dataclass
class TripElement:
"""Trip element."""
start_time: DateOrDateTime
title: str
element_type: str
detail: StrDict
end_time: DateOrDateTime | None = None
start_loc: str | None = None
end_loc: str | None = None
start_country: Country | None = None
end_country: Country | None = None
def get_emoji(self) -> str | None:
"""Emoji for trip element."""
emoji_map = {
"check-in": ":hotel:",
"check-out": ":hotel:",
"train": ":train:",
"flight": ":airplane:",
"ferry": ":ferry:",
}
alias = emoji_map.get(self.element_type)
return emoji.emojize(alias, language="alias") if alias else None
def airport_label(airport: StrDict) -> str:
"""Airport label: name and iata."""
name = airport.get("alt_name") or airport["city"]
return f"{name} ({airport['iata']})"
@dataclass
class Trip:
"""Trip."""
start: datetime.date
travel: list[StrDict] = field(default_factory=list)
accommodation: list[StrDict] = field(default_factory=list)
conferences: list[StrDict] = field(default_factory=list)
events: list[StrDict] = field(default_factory=list)
flight_bookings: list[StrDict] = field(default_factory=list)
name: str | None = None
private: bool = False
@property
def title(self) -> str:
"""Trip title."""
if self.name:
return self.name
titles: list[str] = [conf["name"] for conf in self.conferences] + [
event["title"] for event in self.events
]
if not titles:
for travel in self.travel:
if travel["depart"] and travel["depart"].date() != self.start:
place = travel["from"]
if place not in titles:
titles.append(place)
if travel["depart"] and travel["depart"].date() != self.end:
place = travel["to"]
if place not in titles:
titles.append(place)
return format_list_with_ampersand(titles) or "[unnamed trip]"
@property
def end(self) -> datetime.date | None:
"""End date for trip."""
max_conference_end = (
max(utils.as_date(item["end"]) for item in self.conferences)
if self.conferences
else datetime.date.min
)
assert isinstance(max_conference_end, datetime.date)
arrive = [
utils.as_date(item["arrive"]) for item in self.travel if "arrive" in item
]
travel_end = max(arrive) if arrive else datetime.date.min
assert isinstance(travel_end, datetime.date)
accommodation_end = (
max(utils.as_date(item["to"]) for item in self.accommodation)
if self.accommodation
else datetime.date.min
)
assert isinstance(accommodation_end, datetime.date)
max_date = max(max_conference_end, travel_end, accommodation_end)
return max_date if max_date != datetime.date.min else None
def locations(self) -> list[tuple[str, Country]]:
"""Locations for trip."""
seen: set[tuple[str, str]] = set()
items = []
for item in self.conferences + self.accommodation + self.events:
if "country" not in item or "location" not in item:
continue
key = (item["location"], item["country"])
if key in seen:
continue
seen.add(key)
country = agenda.get_country(item["country"])
assert country
items.append((item["location"], country))
return items
@property
def countries(self) -> list[Country]:
"""Countries visited as part of trip, in order."""
seen: set[str] = set()
items: list[Country] = []
for item in self.conferences + self.accommodation + self.events:
if "country" not in item:
continue
if item["country"] in seen:
continue
seen.add(item["country"])
country = agenda.get_country(item["country"])
assert country
items.append(country)
for item in self.travel:
travel_countries = set()
if item["type"] == "flight":
for key in "from_airport", "to_airport":
c = item[key]["country"]
travel_countries.add(c)
if item["type"] == "train":
for leg in item["legs"]:
for key in "from_station", "to_station":
c = leg[key]["country"]
travel_countries.add(c)
for c in travel_countries - seen:
seen.add(c)
country = agenda.get_country(c)
assert country
items.append(country)
# Don't include GB in countries visited unless entire trip was GB based
return [c for c in items if c.alpha_2 != "GB"] or items
@property
def countries_str(self) -> str:
"""List of countries visited on this trip."""
return format_list_with_ampersand(
[f"{c.name} {c.flag}" for c in self.countries]
)
@property
def locations_str(self) -> str:
"""List of countries visited on this trip."""
return format_list_with_ampersand(
[f"{location} ({c.name}) {c.flag}" for location, c in self.locations()]
)
@property
def country_flags(self) -> str:
"""Countries flags for trip."""
return "".join(c.flag for c in self.countries)
def total_distance(self) -> float | None:
"""Total distance for trip."""
return (
sum(t["distance"] for t in self.travel)
if all(t.get("distance") for t in self.travel)
else None
)
@property
def flights(self) -> list[StrDict]:
"""Flights."""
return [item for item in self.travel if item["type"] == "flight"]
def distances_by_transport_type(self) -> list[tuple[str, float]]:
"""Calculate the total distance travelled for each type of transport.
Any travel item with a missing or None 'distance' field is ignored.
"""
transport_distances: defaultdict[str, float] = defaultdict(float)
for item in self.travel:
distance = item.get("distance")
if distance:
transport_type: str = item.get("type", "unknown")
transport_distances[transport_type] += distance
return list(transport_distances.items())
def elements(self) -> list[TripElement]:
"""Trip elements ordered by time."""
elements: list[TripElement] = []
for item in self.accommodation:
title = "Airbnb" if item.get("operator") == "airbnb" else item["name"]
start = TripElement(
start_time=item["from"],
title=title,
detail=item,
element_type="check-in",
)
elements.append(start)
end = TripElement(
start_time=item["to"],
title=title,
detail=item,
element_type="check-out",
)
elements.append(end)
for item in self.travel:
if item["type"] == "flight":
name = (
f"{airport_label(item['from_airport'])}"
+ f"{airport_label(item['to_airport'])}"
)
from_country = agenda.get_country(item["from_airport"]["country"])
to_country = agenda.get_country(item["to_airport"]["country"])
elements.append(
TripElement(
start_time=item["depart"],
end_time=item.get("arrive"),
title=name,
detail=item,
element_type="flight",
start_loc=airport_label(item["from_airport"]),
end_loc=airport_label(item["to_airport"]),
start_country=from_country,
end_country=to_country,
)
)
if item["type"] == "train":
for leg in item["legs"]:
from_country = agenda.get_country(leg["from_station"]["country"])
to_country = agenda.get_country(leg["to_station"]["country"])
assert from_country and to_country
name = f"{leg['from']}{leg['to']}"
elements.append(
TripElement(
start_time=leg["depart"],
end_time=leg["arrive"],
title=name,
detail=leg,
element_type="train",
start_loc=leg["from"],
end_loc=leg["to"],
start_country=from_country,
end_country=to_country,
)
)
if item["type"] == "ferry":
from_country = agenda.get_country(item["from_terminal"]["country"])
to_country = agenda.get_country(item["to_terminal"]["country"])
name = f"{item['from']}{item['to']}"
elements.append(
TripElement(
start_time=item["depart"],
end_time=item["arrive"],
title=name,
detail=item,
element_type="ferry",
start_loc=item["from"],
end_loc=item["to"],
start_country=from_country,
end_country=to_country,
)
)
return sorted(elements, key=lambda e: utils.as_datetime(e.start_time))
def elements_grouped_by_day(self) -> list[tuple[datetime.date, list[TripElement]]]:
"""Group trip elements by day."""
# Create a dictionary to hold lists of TripElements grouped by their date
grouped_elements: collections.defaultdict[datetime.date, list[TripElement]] = (
collections.defaultdict(list)
)
for element in self.elements():
# Extract the date part of the 'when' attribute
day = utils.as_date(element.start_time)
grouped_elements[day].append(element)
# Sort elements within each day
for day in grouped_elements:
grouped_elements[day].sort(
key=lambda e: (
e.element_type == "check-in", # check-out elements last
e.element_type != "check-out", # check-in elements first
utils.as_datetime(e.start_time), # then sort by time
)
)
# Convert the dictionary to a sorted list of tuples
grouped_elements_list = sorted(grouped_elements.items())
return grouped_elements_list
# Example usage:
# You would call the function with your travel list here to get the results.
@dataclass
class Holiday:
"""Holiay."""
name: str
country: str
date: datetime.date
local_name: str | None = None
@property
def display_name(self) -> str:
"""Format name for display."""
return (
f"{self.name} ({self.local_name})"
if self.local_name and self.local_name != self.name
else self.name
)
@dataclasses.dataclass
emojis = {
"market": "🧺",
"us_presidential_election": "🗳️🇺🇸",
"bus_route_closure": "🚌❌",
"meetup": "👥",
"dinner": "🍷",
"party": "🍷",
"ba_voucher": "✈️",
"accommodation": "🏨", # alternative: 🧳
"flight": "✈️",
"conference": "🎤",
"rocket": "🚀",
"birthday": "🎈",
"waste_schedule": "🗑️",
"economist": "📰",
"running": "🏃",
"critical_mass": "🚴",
"trip": "🧳",
"hackathon": "💻",
}
@dataclass
class Event:
"""Event."""
name: str
date: datetime.date | datetime.datetime
end_date: datetime.date | datetime.datetime | None = None
date: DateOrDateTime
end_date: DateOrDateTime | None = None
title: str | None = None
url: str | None = None
going: bool | None = None
@ -27,13 +389,7 @@ class Event:
@property
def as_datetime(self) -> datetime.datetime:
"""Date/time of event."""
d = self.date
t0 = datetime.datetime.min.time()
return (
d
if isinstance(d, datetime.datetime)
else datetime.datetime.combine(d, t0).replace(tzinfo=datetime.timezone.utc)
)
return utils.as_datetime(self.date)
@property
def has_time(self) -> bool:
@ -78,6 +434,26 @@ class Event:
else None
)
def display_duration(self) -> str | None:
"""Duration for display."""
if self.end_as_date != self.as_date or not self.has_time:
return None
assert isinstance(self.date, datetime.datetime)
assert isinstance(self.end_date, datetime.datetime)
secs: int = int((self.end_date - self.date).total_seconds())
hours: int = secs // 3600
mins: int = (secs % 3600) // 60
if mins == 0:
return f"{hours:d}h"
if hours == 0:
return f"{mins:d} mins"
return f"{hours:d}h {mins:02d} mins"
def delta_days(self, today: datetime.date) -> str:
"""Return number of days from today as a string."""
delta = (self.as_date - today).days
@ -102,3 +478,19 @@ class Event:
def display_title(self) -> str:
"""Name for display."""
return self.title or self.name
@property
def emoji(self) -> str | None:
"""Emoji."""
if self.title == "LHG Run Club":
return "🏃🍻"
return emojis.get(self.name)
@property
def title_with_emoji(self) -> str | None:
"""Title with optional emoji at the start."""
title = self.title or self.name
if title is None:
return None
emoji = self.emoji
return f"{emoji} {title}" if emoji else title

View file

@ -3,29 +3,38 @@
import json
import os
from datetime import date, datetime, timedelta
from time import time
import httpx
from dateutil.easter import easter
from .types import Holiday
from .types import Holiday, StrDict
url = "https://www.gov.uk/bank-holidays.json"
async def bank_holiday_list(
start_date: date, end_date: date, data_dir: str
) -> list[Holiday]:
def json_filename(data_dir: str) -> str:
"""Filename for cached bank holidays."""
assert os.path.exists(data_dir)
return os.path.join(data_dir, "bank-holidays.json")
async def get_holiday_list(data_dir: str) -> list[StrDict]:
"""Download holiday list and save cache."""
filename = json_filename(data_dir)
async with httpx.AsyncClient() as client:
r = await client.get(url)
events: list[StrDict] = r.json()["england-and-wales"]["events"] # check valid
open(filename, "w").write(r.text)
return events
def bank_holiday_list(start_date: date, end_date: date, data_dir: str) -> list[Holiday]:
"""Date and name of the next UK bank holiday."""
url = "https://www.gov.uk/bank-holidays.json"
filename = os.path.join(data_dir, "bank-holidays.json")
mtime = os.path.getmtime(filename)
if (time() - mtime) > 60 * 60 * 6: # six hours
async with httpx.AsyncClient() as client:
r = await client.get(url)
open(filename, "w").write(r.text)
filename = json_filename(data_dir)
events = json.load(open(filename))["england-and-wales"]["events"]
hols: list[Holiday] = []
for event in events:
for event in json.load(open(filename))["england-and-wales"]["events"]:
event_date = datetime.strptime(event["date"], "%Y-%m-%d").date()
if event_date < start_date:
continue

118
agenda/utils.py Normal file
View file

@ -0,0 +1,118 @@
"""Utility functions."""
import os
import typing
from datetime import date, datetime, timedelta, timezone
from time import time
def as_date(d: datetime | date) -> date:
"""Convert datetime to date."""
match d:
case datetime():
return d.date()
case date():
return d
case _:
raise TypeError(f"Unsupported type: {type(d)}")
def as_datetime(d: datetime | date) -> datetime:
"""Date/time of event."""
match d:
case datetime():
return d
case date():
return datetime.combine(d, datetime.min.time()).replace(tzinfo=timezone.utc)
case _:
raise TypeError(f"Unsupported type: {type(d)}")
def timedelta_display(delta: timedelta) -> str:
"""Format timedelta as a human readable string."""
total_seconds = int(delta.total_seconds())
days, remainder = divmod(total_seconds, 24 * 60 * 60)
hours, remainder = divmod(remainder, 60 * 60)
mins, secs = divmod(remainder, 60)
return " ".join(
f"{v} {label}"
for v, label in ((days, "days"), (hours, "hrs"), (mins, "mins"))
if v
)
def human_readable_delta(future_date: date) -> str | None:
"""
Calculate the human-readable time delta for a given future date.
Args:
future_date (date): The future date as a datetime.date object.
Returns:
str: Human-readable time delta.
"""
# Ensure the input is a future date
if future_date <= date.today():
return None
# Calculate the delta
delta = future_date - date.today()
# Convert delta to a more human-readable format
months, days = divmod(delta.days, 30)
weeks, days = divmod(days, 7)
# Formatting the output
parts = []
if months > 0:
parts.append(f"{months} month{'s' if months > 1 else ''}")
if weeks > 0:
parts.append(f"{weeks} week{'s' if weeks > 1 else ''}")
if days > 0:
parts.append(f"{days} day{'s' if days > 1 else ''}")
return " ".join(parts) if parts else None
def filename_timestamp(filename: str, ext: str) -> tuple[datetime, str] | None:
"""Get datetime from filename."""
try:
ts = datetime.strptime(filename, f"%Y-%m-%d_%H:%M:%S.{ext}")
except ValueError:
return None
return (ts, filename)
def get_most_recent_file(directory: str, ext: str) -> str | None:
"""Get most recent file from directory."""
existing = [
x for x in (filename_timestamp(f, ext) for f in os.listdir(directory)) if x
]
if not existing:
return None
existing.sort(reverse=True)
return os.path.join(directory, existing[0][1])
def make_waste_dir(data_dir: str) -> None:
"""Make waste dir if missing."""
waste_dir = os.path.join(data_dir, "waste")
if not os.path.exists(waste_dir):
os.mkdir(waste_dir)
async def time_function(
name: str,
func: typing.Callable[..., typing.Coroutine[typing.Any, typing.Any, typing.Any]],
*args: typing.Any,
**kwargs: typing.Any,
) -> tuple[str, typing.Any, float, Exception | None]:
"""Time the execution of an asynchronous function."""
start_time, result, exception = time(), None, None
try:
result = await func(*args, **kwargs)
except Exception as e:
exception = e
end_time = time()
return name, result, end_time - start_time, exception

View file

@ -1,209 +0,0 @@
"""Waste collection schedules."""
import json
import os
import re
import typing
from collections import defaultdict
from datetime import date, datetime, time, timedelta
import httpx
import lxml.html
from . import uk_time
from .types import Event
ttl_hours = 12
def make_waste_dir(data_dir: str) -> None:
"""Make waste dir if missing."""
waste_dir = os.path.join(data_dir, "waste")
if not os.path.exists(waste_dir):
os.mkdir(waste_dir)
async def get_html(data_dir: str, postcode: str, uprn: str) -> str:
"""Get waste schedule."""
now = datetime.now()
waste_dir = os.path.join(data_dir, "waste")
make_waste_dir(data_dir)
existing_data = os.listdir(waste_dir)
existing = [f for f in existing_data if f.endswith(".html")]
if existing:
recent_filename = max(existing)
recent = datetime.strptime(recent_filename, "%Y-%m-%d_%H:%M.html")
delta = now - recent
if existing and delta < timedelta(hours=ttl_hours):
return open(os.path.join(waste_dir, recent_filename)).read()
now_str = now.strftime("%Y-%m-%d_%H:%M")
filename = f"{waste_dir}/{now_str}.html"
forms_base_url = "https://forms.n-somerset.gov.uk"
# url2 = "https://forms.n-somerset.gov.uk/Waste/CollectionSchedule/ViewSchedule"
url = "https://forms.n-somerset.gov.uk/Waste/CollectionSchedule"
async with httpx.AsyncClient() as client:
r = await client.post(
url,
data={
"PreviousHouse": "",
"PreviousPostcode": "-",
"Postcode": postcode,
"SelectedUprn": uprn,
},
)
form_post_html = r.text
pattern = r'<h2>Object moved to <a href="([^"]*)">here<\/a>\.<\/h2>'
m = re.search(pattern, form_post_html)
if m:
r = await client.get(forms_base_url + m.group(1))
html = r.text
open(filename, "w").write(html)
return html
def parse_waste_schedule_date(day_and_month: str) -> date:
"""Parse waste schedule date."""
today = date.today()
this_year = today.year
date_format = "%A %d %B %Y"
d = datetime.strptime(f"{day_and_month} {this_year}", date_format).date()
if d < today:
d = datetime.strptime(f"{day_and_month} {this_year + 1}", date_format).date()
return d
def parse(root: lxml.html.HtmlElement) -> list[Event]:
"""Parse waste schedule."""
tbody = root.find(".//table/tbody")
assert tbody is not None
by_date = defaultdict(list)
for e_service, e_next_date, e_following in tbody:
assert e_service.text and e_next_date.text and e_following.text
service = e_service.text
next_date = parse_waste_schedule_date(e_next_date.text)
following_date = parse_waste_schedule_date(e_following.text)
by_date[next_date].append(service)
by_date[following_date].append(service)
return [
Event(
name="waste_schedule",
date=uk_time(d, time(6, 30)),
title="🗑️ Backwell: " + ", ".join(services),
)
for d, services in by_date.items()
]
BristolSchedule = list[dict[str, typing.Any]]
async def get_bristol_data(data_dir: str, uprn: str) -> BristolSchedule:
"""Get Bristol Waste schedule, with cache."""
now = datetime.now()
waste_dir = os.path.join(data_dir, "waste")
make_waste_dir(data_dir)
existing_data = os.listdir(waste_dir)
existing = [f for f in existing_data if f.endswith(f"_{uprn}.json")]
if existing:
recent_filename = max(existing)
recent = datetime.strptime(recent_filename, f"%Y-%m-%d_%H:%M_{uprn}.json")
delta = now - recent
def get_from_recent() -> BristolSchedule:
json_data = json.load(open(os.path.join(waste_dir, recent_filename)))
return typing.cast(BristolSchedule, json_data["data"])
if existing and delta < timedelta(hours=ttl_hours):
return get_from_recent()
try:
r = await get_bristol_gov_uk_data(uprn)
except httpx.ReadTimeout:
return get_from_recent()
with open(f'{waste_dir}/{now.strftime("%Y-%m-%d_%H:%M")}_{uprn}.json', "wb") as out:
out.write(r.content)
return typing.cast(BristolSchedule, r.json()["data"])
async def get_bristol_gov_uk_data(uprn: str) -> httpx.Response:
"""Get JSON from Bristol City Council."""
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"
HEADERS = {
"Accept": "*/*",
"Accept-Language": "en-GB,en;q=0.9",
"Connection": "keep-alive",
"Ocp-Apim-Subscription-Key": "47ffd667d69c4a858f92fc38dc24b150",
"Ocp-Apim-Trace": "true",
"Origin": "https://bristolcouncil.powerappsportals.com",
"Referer": "https://bristolcouncil.powerappsportals.com/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "cross-site",
"Sec-GPC": "1",
"User-Agent": UA,
}
_uprn = str(uprn).zfill(12)
async with httpx.AsyncClient(timeout=20) as client:
# Initialise form
payload = {"servicetypeid": "7dce896c-b3ba-ea11-a812-000d3a7f1cdc"}
response = await client.get(
"https://bristolcouncil.powerappsportals.com/completedynamicformunauth/",
headers=HEADERS,
params=payload,
)
host = "bcprdapidyna002.azure-api.net"
# Set the search criteria
payload = {"Uprn": "UPRN" + _uprn}
response = await client.post(
f"https://{host}/bcprdfundyna001-llpg/DetailedLLPG",
headers=HEADERS,
json=payload,
)
# Retrieve the schedule
payload = {"uprn": _uprn}
response = await client.post(
f"https://{host}/bcprdfundyna001-alloy/NextCollectionDates",
headers=HEADERS,
json=payload,
)
return response
async def get_bristol_gov_uk(start_date: date, data_dir: str, uprn: str) -> list[Event]:
"""Get waste collection schedule from Bristol City Council."""
data = await get_bristol_data(data_dir, uprn)
by_date: defaultdict[date, list[str]] = defaultdict(list)
for item in data:
service = item["containerName"]
service = "Recycling" if "Recycling" in service else service.partition(" ")[2]
for collection in item["collection"]:
for collection_date_key in ["nextCollectionDate", "lastCollectionDate"]:
d = date.fromisoformat(collection[collection_date_key][:10])
if d < start_date:
continue
if service not in by_date[d]:
by_date[d].append(service)
return [
Event(name="waste_schedule", date=d, title="🗑️ Bristol: " + ", ".join(services))
for d, services in by_date.items()
]

0
frontend/index.js Normal file
View file

28
package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "agenda",
"version": "1.0.0",
"directories": {
"test": "tests"
},
"repository": {
"type": "git",
"url": "https://git.4angle.com/edward/agenda.git"
},
"license": "ISC",
"devDependencies": {
"copy-webpack-plugin": "^12.0.2",
"eslint": "^9.2.0",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"@fullcalendar/core": "^6.1.11",
"@fullcalendar/daygrid": "^6.1.11",
"@fullcalendar/list": "^6.1.11",
"@fullcalendar/timegrid": "^6.1.11",
"bootstrap": "^5.3.3",
"es-module-shims": "^1.8.3",
"leaflet": "^1.9.4",
"leaflet.geodesic": "^2.7.1"
}
}

View file

@ -9,3 +9,4 @@ dateutil
ephem
flask
requests
emoji

157
static/js/map.js Normal file
View file

@ -0,0 +1,157 @@
if (![].at) {
Array.prototype.at = function(pos) { return this.slice(pos, pos + 1)[0] }
}
function emoji_icon(emoji) {
var iconStyle = "<div style='background-color: white; border-radius: 50%; width: 30px; height: 30px; display: flex; justify-content: center; align-items: center; border: 1px solid black;'> <div style='font-size: 18px;'>" + emoji + "</div></div>";
return L.divIcon({
className: 'custom-div-icon',
html: iconStyle,
iconSize: [60, 60],
iconAnchor: [15, 15],
});
}
var icons = {
"station": emoji_icon("🚉"),
"airport": emoji_icon("✈️"),
"ferry_terminal": emoji_icon("🚢"),
"accommodation": emoji_icon("🏨"),
"conference": emoji_icon("🖥️"),
"event": emoji_icon("🍷"),
}
function build_map(map_id, coordinates, routes) {
var map = L.map(map_id).fitBounds(coordinates.map(station => [station.latitude, station.longitude]));
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
var markers = [];
var offset_lines = [];
function getIconBounds(latlng) {
let iconSize = 20; // Assuming the icon size as a square
if (!latlng) return null;
let pixel = map.project(latlng, map.getZoom());
let sw = map.unproject([pixel.x - iconSize / 2, pixel.y + iconSize / 2], map.getZoom());
let ne = map.unproject([pixel.x + iconSize / 2, pixel.y - iconSize / 2], map.getZoom());
return L.latLngBounds(sw, ne);
}
function calculateCentroid(markers) {
let latSum = 0, lngSum = 0, count = 0;
markers.forEach(marker => {
latSum += marker.getLatLng().lat;
lngSum += marker.getLatLng().lng;
count++;
});
return count > 0 ? L.latLng(latSum / count, lngSum / count) : null;
}
// Function to detect and group overlapping markers
function getOverlappingGroups() {
let groups = [];
let visited = new Set();
markers.forEach((marker, index) => {
if (visited.has(marker)) {
return;
}
let group = [];
let markerBounds = getIconBounds(marker.getLatLng());
markers.forEach((otherMarker) => {
if (marker !== otherMarker && markerBounds.intersects(getIconBounds(otherMarker.getLatLng()))) {
group.push(otherMarker);
visited.add(otherMarker);
}
});
if (group.length > 0) {
group.push(marker); // Add the original marker to the group
groups.push(group);
visited.add(marker);
}
});
return groups;
}
function displaceMarkers(group, zoom) {
const markerPixelSize = 30; // Width/height of the marker in pixels
let map = group[0]._map; // Assuming all markers are on the same map
let centroid = calculateCentroid(group);
let centroidPoint = map.project(centroid, zoom);
const radius = markerPixelSize; // Set radius for even distribution
const angleIncrement = (2 * Math.PI) / group.length; // Evenly space markers
group.forEach((marker, index) => {
let angle = index * angleIncrement;
let newX = centroidPoint.x + radius * Math.cos(angle);
let newY = centroidPoint.y + radius * Math.sin(angle);
let newPoint = L.point(newX, newY);
let newLatLng = map.unproject(newPoint, zoom);
// Store original position for polyline
let originalPos = marker.getLatLng();
marker.setLatLng(newLatLng);
marker.polyline = L.polyline([originalPos, newLatLng], {color: "gray", weight: 2}).addTo(map);
offset_lines.push(marker.polyline);
});
}
coordinates.forEach(function(item, index) {
let latlng = L.latLng(item.latitude, item.longitude);
let marker = L.marker(latlng, { icon: icons[item.type] }).addTo(map);
marker.bindPopup(item.name);
markers.push(marker);
});
map.on('zoomend', function() {
markers.forEach((marker, index) => {
marker.setLatLng([coordinates[index].latitude, coordinates[index].longitude]); // Reset position on zoom
if (marker.polyline) {
map.removeLayer(marker.polyline);
}
});
offset_lines.forEach(polyline => {
map.removeLayer(polyline);
});
let overlappingGroups = getOverlappingGroups();
// console.log(overlappingGroups); // Process or display groups as needed
overlappingGroups.forEach(group => displaceMarkers(group, map.getZoom()));
});
let overlappingGroups = getOverlappingGroups();
// console.log(overlappingGroups); // Process or display groups as needed
overlappingGroups.forEach(group => displaceMarkers(group, map.getZoom()));
// Draw routes
routes.forEach(function(route) {
var color = {"train": "blue", "flight": "red", "unbooked_flight": "orange"}[route.type];
var style = { weight: 3, opacity: 0.5, color: color };
if (route.geojson) {
L.geoJSON(JSON.parse(route.geojson), {
style: function(feature) { return style; }
}).addTo(map);
} else if (route.type === "flight" || route.type === "unbooked_flight") {
var flightPath = new L.Geodesic([[route.from, route.to]], style).addTo(map);
} else {
L.polyline([route.from, route.to], style).addTo(map);
}
});
return map;
}

View file

@ -1,9 +1,12 @@
{% extends "base.html" %}
{% from "macros.html" import trip_link, accommodation_row with context %}
{% block title %}Accommodation - Edward Betts{% endblock %}
{% block style %}
{% set column_count = 9 %}
<style>
.grid-container {
display: grid;
grid-template-columns: repeat(6, auto);
grid-template-columns: repeat({{ column_count }}, auto);
gap: 10px;
justify-content: start;
}
@ -13,24 +16,18 @@
}
.heading {
grid-column: 1 / 7; /* Spans from the 1st line to the 7th line */
grid-column: 1 / {{ column_count + 1 }}; /* Spans from the 1st line to the 7th line */
}
</style>
{% endblock %}
{% macro row(item, badge) %}
<div class="grid-item text-end">{{ item.from.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item text-end">{{ item.to.strftime("%a, %d %b") }}</div>
<div class="grid-item text-end">{{ (item.to.date() - item.from.date()).days }}</div>
<div class="grid-item">{{ item.name }}</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">{{ item.location }}</div>
{% endmacro %}
{% macro section(heading, item_list, badge) %}
{% if item_list %}
<div class="heading"><h2>{{heading}}</h2></div>
{% for item in item_list %}{{ row(item, badge) }}{% endfor %}
{% for item in item_list %}
{{ accommodation_row(item, badge) }}
<div class="grid-item">{% if item.linked_trip %} trip: {{ trip_link(item.linked_trip) }} {% endif %}</div>
{% endfor %}
{% endif %}
{% endmacro %}
@ -46,7 +43,9 @@
</ul>
<div class="grid-container">
{{ section("Accommodation", items) }}
{{ section("Current", current) }}
{{ section("Future", future) }}
{{ section("Past", past) }}
</div>
</div>

View file

@ -7,7 +7,7 @@
<title>{% block title %}{% endblock %}</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet">
{% block style %}
{% endblock %}
@ -18,5 +18,6 @@
{% block nav %}{{ navbar() }}{% endblock %}
{% block content %}{% endblock %}
{% block scripts %}{% endblock %}
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js") }}"></script>
</body>
</html>

View file

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% from "macros.html" import display_date %}
{% block title %}Birthdays - Edward Betts{% endblock %}
{% block content %}
<div class="container-fluid mt-2">
<h1>Birthdays</h1>
<table class="w-auto table">
{% for event in items %}
<tr>
<td class="text-end">{{event.as_date.strftime("%a, %d, %b %Y")}}</td>
<td>{{ event.title }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}

147
templates/calendar.html Normal file
View file

@ -0,0 +1,147 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agenda - Edward Betts</title>
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>">
<script async src="{{ url_for("static", filename="es-module-shims/es-module-shims.js") }}"></script>
<script type='importmap'>
{
"imports": {
"@fullcalendar/core": "https://cdn.skypack.dev/@fullcalendar/core@6.1.9",
"@fullcalendar/daygrid": "https://cdn.skypack.dev/@fullcalendar/daygrid@6.1.9",
"@fullcalendar/timegrid": "https://cdn.skypack.dev/@fullcalendar/timegrid@6.1.9",
"@fullcalendar/list": "https://cdn.skypack.dev/@fullcalendar/list@6.1.9",
"@fullcalendar/core/locales/en-gb": "https://cdn.skypack.dev/@fullcalendar/core@6.1.9/locales/en-gb"
}
}
</script>
<script type='module'>
import { Calendar } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import gbLocale from '@fullcalendar/core/locales/en-gb';
// Function to save the current view to local storage
function saveView(view) {
localStorage.setItem('fullCalendarDefaultView', view);
}
// Function to get the saved view from local storage
function getSavedView() {
return localStorage.getItem('fullCalendarDefaultView') || 'dayGridMonth';
}
document.addEventListener('DOMContentLoaded', function() {
const calendarEl = document.getElementById('calendar')
const calendar = new Calendar(calendarEl, {
locale: gbLocale,
plugins: [dayGridPlugin, timeGridPlugin, listPlugin ],
themeSystem: 'bootstrap5',
firstDay: 1,
initialView: getSavedView(),
viewDidMount: function(info) {
saveView(info.view.type);
},
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
nowIndicator: true,
weekNumbers: true,
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false,
},
events: {{ fullcalendar_events | tojson(indent=2) }},
eventDidMount: function(info) {
info.el.title = info.event.title;
},
})
calendar.render()
})
</script>
</head>
{% set event_labels = {
"economist": "📰 The Economist",
"mothers_day": "Mothers' day",
"fathers_day": "Fathers' day",
"uk_financial_year_end": "End of financial year",
"bank_holiday": "UK bank holiday",
"us_holiday": "US holiday",
"uk_clock_change": "UK clock change",
"us_clock_change": "US clock change",
"us_presidential_election": "US pres. election",
"xmas_last_second": "Christmas last posting 2nd class",
"xmas_last_first": "Christmas last posting 1st class",
"up_series": "Up documentary",
"waste_schedule": "Waste schedule",
"gwr_advance_tickets": "GWR advance tickets",
"critical_mass": "Critical Mass",
}
%}
{%set class_map = {
"bank_holiday": "bg-success-subtle",
"conference": "bg-primary-subtle",
"us_holiday": "bg-secondary-subtle",
"birthday": "bg-info-subtle",
"waste_schedule": "bg-danger-subtle",
} %}
{% from "navbar.html" import navbar with context %}
<body>
{{ navbar() }}
<div class="container-fluid mt-2">
<h1>Agenda</h1>
<p>
<a href="/tools">&larr; personal tools</a>
</p>
{% if errors %}
{% for error in errors %}
<div class="alert alert-danger" role="alert">
Error: {{ error }}
</div>
{% endfor %}
{% endif %}
<div>
Markets:
<a href="{{ url_for(request.endpoint) }}">Hide while away</a>
| <a href="{{ url_for(request.endpoint, markets="show") }}">Show all</a>
| <a href="{{ url_for(request.endpoint, markets="hide") }}">Hide all</a>
</div>
<div class="mb-3" id="calendar"></div>
<div class="mt-2">
<h5>Page generation time</h5>
<ul>
<li>Data gather took {{ "%.1f" | format(data_gather_seconds) }} seconds</li>
<li>Stock market open/close took
{{ "%.1f" | format(stock_market_times_seconds) }} seconds</li>
{% for name, seconds in timings %}
<li>{{ name }} took {{ "%.1f" | format(seconds) }} seconds</li>
{% endfor %}
</ul>
</div>
</div>
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js") }}"></script>
</body>
</html>

View file

@ -1,10 +1,15 @@
{% extends "base.html" %}
{% from "macros.html" import trip_link, conference_row with context %}
{% block title %}Conferences - Edward Betts{% endblock %}
{% block style %}
{% set column_count = 9 %}
<style>
.grid-container {
display: grid;
grid-template-columns: repeat(6, auto); /* 7 columns for each piece of information */
grid-template-columns: repeat({{ column_count }}, auto); /* 7 columns for each piece of information */
gap: 10px;
justify-content: start;
}
@ -14,45 +19,27 @@
}
.heading {
grid-column: 1 / 7; /* Spans from the 1st line to the 7th line */
grid-column: 1 / {{ column_count + 1 }}; /* Spans from the 1st line to the 7th line */
}
</style>
{% endblock %}
{% macro row(item, badge) %}
<div class="grid-item text-end">{{ item.start.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item text-end">{{ item.end.strftime("%a, %d %b") }}</div>
<div class="grid-item">{{ item.name }}
{% if item.going and not (item.accommodation_booked or item.travel_booked) %}
<span class="badge text-bg-primary">
{{ badge }}
</span>
{% endif %}
{% if item.accommodation_booked %}
<span class="badge text-bg-success">accommodation</span>
{% endif %}
{% if item.transport_booked %}
<span class="badge text-bg-success">transport</span>
{% endif %}
</div>
<div class="grid-item">{{ item.topic }}</div>
<div class="grid-item">{{ item.location }}</div>
<div class="grid-item"><a href="{{ item.url }}">{{ item.url }}</a></div>
{% endmacro %}
{% macro section(heading, item_list, badge) %}
{% if item_list %}
<div class="heading"><h2>{{heading}}</h2></div>
{% for item in item_list %}{{ row(item, badge) }}{% endfor %}
{% endif %}
{% if item_list %}
<div class="heading"><h2>{{ heading }}</h2></div>
{% for item in item_list %}
{{ conference_row(item, badge) }}
<div class="grid-item">
{% if item.linked_trip %} trip: {{ trip_link(item.linked_trip) }} {% endif %}
</div>
{% endfor %}
{% endif %}
{% endmacro %}
{% block content %}
<div class="container-fluid mt-2">
<h1>Conferences</h1>
<div class="grid-container">
{{ section("Current", current, "attending") }}
{{ section("Future", future, "going") }}

175
templates/event_list.html Normal file
View file

@ -0,0 +1,175 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agenda - Edward Betts</title>
<link href="{{ url_for("static", filename="bootstrap5/css/bootstrap.min.css") }}" rel="stylesheet">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>">
<script async src="{{ url_for("static", filename="es-module-shims/es-module-shims.js") }}"></script>
</head>
{% set event_labels = {
"economist": "📰 The Economist",
"mothers_day": "Mothers' day",
"fathers_day": "Fathers' day",
"uk_financial_year_end": "End of financial year",
"bank_holiday": "UK bank holiday",
"us_holiday": "US holiday",
"uk_clock_change": "UK clock change",
"us_clock_change": "US clock change",
"us_presidential_election": "US pres. election",
"xmas_last_second": "Christmas last posting 2nd class",
"xmas_last_first": "Christmas last posting 1st class",
"up_series": "Up documentary",
"waste_schedule": "Waste schedule",
"gwr_advance_tickets": "GWR advance tickets",
"critical_mass": "Critical Mass",
}
%}
{%set class_map = {
"bank_holiday": "bg-success-subtle",
"conference": "bg-primary-subtle",
"us_holiday": "bg-secondary-subtle",
"birthday": "bg-info-subtle",
"waste_schedule": "bg-danger-subtle",
} %}
{% from "macros.html" import trip_link, display_date_no_year with context %}
{% from "navbar.html" import navbar with context %}
<body>
{{ navbar() }}
<div class="container-fluid mt-2">
<h1>Agenda</h1>
<p>
<a href="/tools">&larr; personal tools</a>
</p>
<ul>
<li>Today is {{now.strftime("%A, %-d %b %Y")}}</li>
{% if gbpusd %}
<li>GBPUSD: {{"{:,.3f}".format(gbpusd)}}</li>
{% endif %}
<li>GWR advance ticket furthest date:
{% if gwr_advance_tickets %}
{{ gwr_advance_tickets.strftime("%A, %-d %b %Y") }}
{% else %}
unknown
{% endif %}
</li>
<li>Bristol Sunrise: {{ sunrise.strftime("%H:%M:%S") }} /
Sunset: {{ sunset.strftime("%H:%M:%S") }}</li>
</ul>
{% if errors %}
{% for error in errors %}
<div class="alert alert-danger" role="alert">
Error: {{ error }}
</div>
{% endfor %}
{% endif %}
<h3>Stock markets</h3>
{% for market in stock_markets %}
<p>{{ market }}</p>
{% endfor %}
{% if current_trip %}
{% set end = current_trip.end %}
<div>
<div>Current trip: {{ trip_link(current_trip) }}</div>
{% if end %}
<div>Dates: {{ display_date_no_year(current_trip.start) }} to {{ display_date_no_year(end) }}</div>
{% else %}
<div>Start: {{ display_date_no_year(current_trip.start) }} (end date missing)</div>
{% endif %}
</div>
{% endif %}
<h3>Agenda</h3>
<div>
Markets:
<a href="{{ url_for(request.endpoint) }}">Hide while away</a>
| <a href="{{ url_for(request.endpoint, markets="show") }}">Show all</a>
| <a href="{{ url_for(request.endpoint, markets="hide") }}">Hide all</a>
</div>
{% for event in events if start_event_list <= event.as_date <= end_event_list %}
{% if loop.first or event.date.year != loop.previtem.date.year or event.date.month != loop.previtem.date.month %}
<div class="row mt-2">
<div class="col">
<h4>{{ event.date.strftime("%B %Y") }}</h4>
</div>
</div>
{% endif %}
{% set delta = event.delta_days(today) %}
{% if event.name == "today" %}
<div class="row">
<div class="col bg-warning-subtle">
<h3>today</h3>
</div>
</div>
{% else %}
{% set cell_bg = " bg-warning-subtle" if delta == "today" else "" %}
<div class="row border border-1 {% if event.name in class_map %} {{ class_map[event.name]}}{% else %}{{ cell_bg }}{% endif %}">
<div class="col-md-2{{ cell_bg }}">
{{event.as_date.strftime("%a, %d, %b")}}
&nbsp;
&nbsp;
{{event.display_time or ""}}
&nbsp;
&nbsp;
{{event.display_timezone or ""}}
</div>
<div class="col-md-2{{ cell_bg }}">
{% if event.end_date %}
{% set duration = event.display_duration() %}
{% if duration %}
end: {{event.end_date.strftime("%H:%M") }}
(duration: {{duration}})
{% elif event.end_date != event.date %}
{{event.end_date}}
{% endif %}
{% endif %}
</div>
<div class="col-md-7 text-start">
{% if event.url %}<a href="{{ event.url }}">{% endif %}
{{ event_labels.get(event.name) or event.name }}
{%- if event.title -%}: {{ event.title_with_emoji }}{% endif %}
{% if event.url %}</a>{% endif %}
</div>
<div class="col-md-1{{ cell_bg }}">
{{ delta }}
</div>
</div>
{% endif %}
{% endfor %}
<div class="mt-2">
<h5>Page generation time</h5>
<ul>
<li>Data gather took {{ "%.1f" | format(data_gather_seconds) }} seconds</li>
<li>Stock market open/close took
{{ "%.1f" | format(stock_market_times_seconds) }} seconds</li>
{% for name, seconds in timings %}
<li>{{ name }} took {{ "%.1f" | format(seconds) }} seconds</li>
{% endfor %}
<li>Render time: {{ "%.1f" | format(render_time) }} seconds</li>
</ul>
</div>
</div>
<script src="{{ url_for("static", filename="bootstrap5/js/bootstrap.bundle.min.js") }}"></script>
</body>
</html>

View file

@ -1,4 +1,5 @@
{% extends "base.html" %}
{% block title %}Gaps - Edward Betts{% endblock %}
{% block content %}
<div class="p-2">
@ -17,11 +18,31 @@
<tbody>
{% for gap in gaps %}
<tr>
<td>{% for event in gap.before %}{% if not loop.first %}<br/>{% endif %}<span class="text-nowrap">{{ event.title or event.name }}</span>{% endfor %}</td>
<td class="text-start">
{% for event in gap.before %}
<div>
{% if event.url %}
<a href="{{event.url}}">{{ event.title_with_emoji }}</a>
{% else %}
{{ event.title_with_emoji }}
{% endif %}
</div>
{% endfor %}
</td>
<td class="text-end text-nowrap">{{ gap.start.strftime("%A, %-d %b %Y") }}</td>
<td class="text-end text-nowrap">{{ (gap.end - gap.start).days }} days</td>
<td class="text-end text-nowrap">{{ gap.end.strftime("%A, %-d %b %Y") }}</td>
<td>{% for event in gap.after %}{% if not loop.first %}<br/>{% endif %}<span class="text-nowrap">{{ event.title or event.name }}</span>{% endfor %}</td>
<td class="text-start">
{% for event in gap.after %}
<div>
{% if event.url %}
<a href="{{event.url}}">{{ event.title_with_emoji }}</a>
{% else %}
{{ event.title_with_emoji }}
{% endif %}
</div>
{% endfor %}
</td>
</tr>
{% endfor %}
</tbody>

View file

@ -0,0 +1,24 @@
{% extends "base.html" %}
{% from "macros.html" import display_date %}
{% block title %}Holidays - Edward Betts{% endblock %}
{% block content %}
<div class="container-fluid mt-2">
<h1>Holidays</h1>
<table class="table table-hover w-auto">
{% for item in items %}
{% set country = get_country(item.country) %}
<tr>
{% if loop.first or item.date != loop.previtem.date %}
<td class="text-end">{{ display_date(item.date) }}</td>
<td>in {{ (item.date - today).days }} days</td>
{% else %}
<td colspan="2"></td>
{% endif %}
<td>{{ country.flag }} {{ country.name }}</td>
<td>{{ item.display_name }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endblock %}

View file

@ -1,240 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Agenda</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>📅</text></svg>">
<script async src="https://unpkg.com/es-module-shims@1.8.2/dist/es-module-shims.js"></script>
<script type='importmap'>
{
"imports": {
"@fullcalendar/core": "https://cdn.skypack.dev/@fullcalendar/core@6.1.9",
"@fullcalendar/daygrid": "https://cdn.skypack.dev/@fullcalendar/daygrid@6.1.9",
"@fullcalendar/timegrid": "https://cdn.skypack.dev/@fullcalendar/timegrid@6.1.9",
"@fullcalendar/list": "https://cdn.skypack.dev/@fullcalendar/list@6.1.9",
"@fullcalendar/core/locales/en-gb": "https://cdn.skypack.dev/@fullcalendar/core@6.1.9/locales/en-gb"
}
}
</script>
<script type='module'>
import { Calendar } from '@fullcalendar/core'
import dayGridPlugin from '@fullcalendar/daygrid'
import timeGridPlugin from '@fullcalendar/timegrid'
import listPlugin from '@fullcalendar/list'
import gbLocale from '@fullcalendar/core/locales/en-gb';
// Function to save the current view to local storage
function saveView(view) {
localStorage.setItem('fullCalendarDefaultView', view);
}
// Function to get the saved view from local storage
function getSavedView() {
return localStorage.getItem('fullCalendarDefaultView') || 'dayGridMonth';
}
document.addEventListener('DOMContentLoaded', function() {
const calendarEl = document.getElementById('calendar')
const calendar = new Calendar(calendarEl, {
locale: gbLocale,
plugins: [dayGridPlugin, timeGridPlugin, listPlugin ],
themeSystem: 'bootstrap5',
firstDay: 1,
initialView: getSavedView(),
viewDidMount: function(info) {
saveView(info.view.type);
},
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
nowIndicator: true,
weekNumbers: true,
eventTimeFormat: {
hour: '2-digit',
minute: '2-digit',
hour12: false,
},
events: {{ fullcalendar_events | tojson(indent=2) }},
eventDidMount: function(info) {
info.el.title = info.event.title;
},
})
calendar.render()
})
</script>
</head>
{% set event_labels = {
"economist": "📰 The Economist",
"mothers_day": "Mothers' day",
"fathers_day": "Fathers' day",
"uk_financial_year_end": "End of financial year",
"bank_holiday": "UK bank holiday",
"us_holiday": "US holiday",
"uk_clock_change": "UK clock change",
"us_clock_change": "US clock change",
"us_presidential_election": "US pres. election",
"xmas_last_second": "Christmas last posting 2nd class",
"xmas_last_first": "Christmas last posting 1st class",
"up_series": "Up documentary",
"waste_schedule": "Waste schedule",
"gwr_advance_tickets": "GWR advance tickets",
"critical_mass": "Critical Mass",
}
%}
{%set class_map = {
"bank_holiday": "bg-success-subtle",
"conference": "bg-primary-subtle",
"us_holiday": "bg-secondary-subtle",
"birthday": "bg-info-subtle",
"waste_schedule": "bg-danger-subtle",
} %}
{% from "navbar.html" import navbar with context %}
<body>
{{ navbar() }}
<div class="container-fluid mt-2">
<h1>Agenda</h1>
<p>
<a href="/tools">&larr; personal tools</a>
</p>
<ul>
<li>Today is {{now.strftime("%A, %-d %b %Y")}}</li>
{% if gbpusd %}
<li>GBPUSD: {{"{:,.3f}".format(gbpusd)}}</li>
{% endif %}
<li>GWR advance ticket furthest date:
{% if gwr_advance_tickets %}
{{ gwr_advance_tickets.strftime("%A, %-d %b %Y") }}
{% else %}
unknown
{% endif %}
</li>
<li>Bristol Sunrise: {{ sunrise.strftime("%H:%M:%S") }} /
Sunset: {{ sunset.strftime("%H:%M:%S") }}</li>
</ul>
<h3>Stock markets</h3>
{% for market in stock_markets %}
<p>{{ market }}</p>
{% endfor %}
<div class="mb-3" id="calendar"></div>
<h3>Agenda</h3>
{% for event in events if event.as_date >= two_weeks_ago %}
{% if loop.first or event.date.year != loop.previtem.date.year or event.date.month != loop.previtem.date.month %}
<div class="row mt-2">
<div class="col">
<h4>{{ event.date.strftime("%B %Y") }}</h4>
</div>
</div>
{% endif %}
{% set delta = event.delta_days(today) %}
{% if event.name == "today" %}
<div class="row">
<div class="col bg-warning-subtle">
<h3>today</h3>
</div>
</div>
{% else %}
{% set cell_bg = " bg-warning-subtle" if delta == "today" else "" %}
<div class="row border border-1 {% if event.name in class_map %} {{ class_map[event.name]}}{% else %}{{ cell_bg }}{% endif %}">
<div class="col-md-2{{ cell_bg }}">
{{event.as_date.strftime("%a, %d, %b")}}
&nbsp;
&nbsp;
{{event.display_time or ""}}
&nbsp;
&nbsp;
{{event.display_timezone or ""}}
</div>
<div class="col-md-2{{ cell_bg }}">
{% if event.end_date %}
{% if event.end_as_date == event.as_date and event.has_time %}
end: {{event.end_date.strftime("%H:%M") }}
(duration: {{event.end_date - event.date}})
{% elif event.end_date != event.date %}
{{event.end_date}}
{% endif %}
{% endif %}
</div>
<div class="col-md-7 text-start">
{% if event.url %}<a href="{{ event.url }}">{% endif %}
{{ event_labels.get(event.name) or event.name }}
{%- if event.title -%}: {{ event.title }}{% endif %}
{% if event.url %}</a>{% endif %}
</div>
<div class="col-md-1{{ cell_bg }}">
{{ delta }}
</div>
</div>
{% endif %}
{% endfor %}
<h3>Space launches</h3>
{% for launch in rockets %}
<div class="row">
<div class="col-md-1 text-nowrap text-md-end">{{ launch.t0_date }}
<br class="d-none d-md-block"/>
{% if launch.t0_time %}
{{ launch.t0_time }}{% endif %}
{{ launch.net_precision }}
</div>
<div class="col-md-1 text-md-nowrap">
<span class="d-md-none">launch status:</span>
<abbr title="{{ launch.status.name }}">{{ launch.status.abbrev }}</abbr>
</div>
<div class="col">{{ launch.rocket }}
&ndash;
<strong>{{launch.mission.name }}</strong>
&ndash;
{% if launch.launch_provider_abbrev %}
<abbr title="{{ launch.launch_provider }}">{{ launch.launch_provider_abbrev }}</abbr>
{% else %}
{{ launch.launch_provider }}
{% endif %}
({{ launch.launch_provider_type }})
&mdash;
{{ launch.orbit.name }} ({{ launch.orbit.abbrev }})
<br/>
{% if launch.pad_wikipedia_url %}
<a href="{{ launch.pad_wikipedia_url }}">{{ launch.pad_name }}</a>
{% else %}
{{ launch.pad_name }} {% if launch.pad_name != "Unknown Pad" %}(no Wikipedia article){% endif %}
{% endif %}
&mdash; {{ launch.location }}<br/>
{% if launch.mission %}
{% for line in launch.mission.description.splitlines() %}
<p>{{ line }}</p>
{% endfor %}
{% else %}
<p>No description.</p>
{% endif %}
</div>
</div>
{% endfor %}
</div>
</body>
</html>

122
templates/launches.html Normal file
View file

@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}Space launches - Edward Betts{% endblock %}
{% block content %}
<div class="container-fluid mt-2">
<h1>Space launches</h1>
<h4>Filters</h4>
<p>Mission type:
{% if request.args.type %}<a href="{{ request.path }}">🗙</a>{% endif %}
{% for t in mission_types | sort %}
{% if t == request.args.type %}
<strong>{{ t }}</strong>
{% else %}
<a href="?type={{ t }}" class="text-nowrap">
{{ t }}
</a>
{% endif %}
{% if not loop.last %} | {% endif %}
{% endfor %}
</p>
<p>Vehicle:
{% if request.args.rocket %}<a href="{{ request.path }}">🗙</a>{% endif %}
{% for r in rockets | sort %}
{% if r == request.args.rockets %}
<strong>{{ r }}</strong>
{% else %}
<a href="?rocket={{ r }}" class="text-nowrap">
{{ r }}
</a>
{% endif %}
{% if not loop.last %} | {% endif %}
{% endfor %}
</p>
<p>Orbit:
{% if request.args.orbit %}<a href="{{ request.path }}">🗙</a>{% endif %}
{% for name, abbrev in orbits | sort %}
{% if abbrev == request.args.orbit %}
<strong>{{ name }}</strong>
{% else %}
<a href="?orbit={{ abbrev }}" class="text-nowrap">
{{ name }}
</a>
{% endif %}
{% if not loop.last %} | {% endif %}
{% endfor %}
</p>
{% for launch in launches %}
{% set highlight =" bg-primary-subtle" if launch.slug in config.FOLLOW_LAUNCHES else "" %}
{% set country = get_country(launch.country_code) %}
<div class="row{{highlight}}">
<div class="col-md-1 text-nowrap text-md-end">{{ launch.t0_date }}
<br class="d-none d-md-block"/>
{% if launch.t0_time %}
{{ launch.t0_time }}{% endif %}
{{ launch.net_precision }}
</div>
<div class="col-md-1 text-md-nowrap">
<span class="d-md-none">launch status:</span>
<abbr title="{{ launch.status.name }}">{{ launch.status.abbrev }}</abbr>
</div>
<div class="col">
<div>
{{ country.flag }}
{{ launch.rocket.full_name }}
&ndash;
<strong>{{launch.mission.name }}</strong>
&ndash;
{% if launch.launch_provider_abbrev %}
<abbr title="{{ launch.launch_provider }}">{{ launch.launch_provider_abbrev }}</abbr>
{% else %}
{{ launch.launch_provider }}
{% endif %}
({{ launch.launch_provider_type }})
&mdash;
{{ launch.orbit.name }} ({{ launch.orbit.abbrev }})
&mdash;
{{ launch.mission.type }}
</div>
<div>
{% if launch.pad_wikipedia_url %}
<a href="{{ launch.pad_wikipedia_url }}">{{ launch.pad_name }}</a>
{% else %}
{{ launch.pad_name }} {% if launch.pad_name != "Unknown Pad" %}(no Wikipedia article){% endif %}
{% endif %}
&mdash; {{ launch.location }}
</div>
{% if launch.mission.agencies | count %}
<div>
{% for agency in launch.mission.agencies %}
{%- if not loop.first %}, {% endif %}
<a href="{{ agency.wiki_url }}">{{agency.name }}</a>
{{ get_country(agency.country_code).flag }}
({{ agency.type }}) {# <img src="{{ agency.logo_url }}"/> #}
{% endfor %}
</div>
{% endif %}
<div>
{% if launch.mission %}
{% for line in launch.mission.description.splitlines() %}
<p>{{ line }}</p>
{% endfor %}
{% else %}
<p>No description.</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endblock %}

267
templates/macros.html Normal file
View file

@ -0,0 +1,267 @@
{% macro display_datetime(dt) %}{{ dt.strftime("%a, %d, %b %Y %H:%M %z") }}{% endmacro %}
{% macro display_time(dt) %}
{% if dt %}{{ dt.strftime("%H:%M %z") }}{% endif %}
{% endmacro %}
{% macro display_date(dt) %}{{ dt.strftime("%a %-d %b %Y") }}{% endmacro %}
{% macro display_date_no_year(dt) %}{{ dt.strftime("%a %-d %b") }}{% endmacro %}
{% macro format_distance(distance) %}
{{ "{:,.0f} km / {:,.0f} miles".format(distance, distance / 1.60934) }}
{% endmacro %}
{% macro trip_link(trip) %}
<a href="{{ url_for("trip_page", start=trip.start.isoformat()) }}">{{ trip.title }}</a>
{% endmacro %}
{% macro conference_row(item, badge) %}
{% set country = get_country(item.country) if item.country else None %}
<div class="grid-item text-end">{{ item.start.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item text-end">{{ item.end.strftime("%a, %d %b") }}</div>
<div class="grid-item">
{% if item.url %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
{% if item.going and not (item.accommodation_booked or item.travel_booked) %}
<span class="badge text-bg-primary">
{{ badge }}
</span>
{% endif %}
{% if item.accommodation_booked %}
<span class="badge text-bg-success">accommodation</span>
{% endif %}
{% if item.transport_booked %}
<span class="badge text-bg-success">transport</span>
{% endif %}
</div>
<div class="grid-item text-end">
{% if item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,d}".format(item.price | int) }} {{ item.currency }}</span>
{% if item.currency != "GBP" and item.currency in fx_rate %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% elif item.free %}
<span class="badge bg-success text-nowrap">free to attend</span>
{% endif %}
</div>
<div class="grid-item">{{ item.topic }}</div>
<div class="grid-item">{{ item.location }}</div>
<div class="grid-item text-end">{{ display_date(item.cfp_end) if item.cfp_end else "" }}</div>
<div class="grid-item">
{% if country %}
{{ country.flag }} {{ country.name }}
{% elif item.online %}
💻 Online
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
</div>
{% endmacro %}
{% macro accommodation_row(item, badge) %}
{% set country = get_country(item.country) %}
{% set nights = (item.to.date() - item.from.date()).days %}
<div class="grid-item text-end">{{ item.from.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item text-end">{{ item.to.strftime("%a, %d %b") }}</div>
<div class="grid-item text-end">{% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %}</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">{{ item.location }}</div>
<div class="grid-item">
{% if country %}
{{ country.flag }} {{ country.name }}
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
</div>
<div class="grid-item">
{% if g.user.is_authenticated and item.url %}
<a href="{{ item.url }}">{{ item.name }}</a>
{% else %}
{{ item.name }}
{% endif %}
</div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(item.price) }} {{ item.currency }}</span>
{% if item.currency != "GBP" %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{% endmacro %}
{% macro flight_booking_row(booking) %}
<div class="grid-item">
{% if g.user.is_authenticated %}
{{ booking.booking_reference or "reference missing" }}
{% else %}
<em>redacted</em>
{% endif %}
</div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and booking.price and booking.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(booking.price) }} {{ booking.currency }}</span>
{% if booking.currency != "GBP" %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(booking.price / fx_rate[booking.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{% for i in range(8) %}
<div class="grid-item"></div>
{% endfor %}
{% for item in booking.flights %}
{% set full_flight_number = item.airline + item.flight_number %}
{% set radarbox_url = "https://www.radarbox.com/data/flights/" + full_flight_number %}
<div class="grid-item"></div>
<div class="grid-item"></div>
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">{{ item.from }} &rarr; {{ item.to }}</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ item.duration }}</div>
<div class="grid-item">{{ full_flight_number }}</div>
<div class="grid-item">
<a href="https://www.flightradar24.com/data/flights/{{ full_flight_number | lower }}">flightradar24</a>
| <a href="https://uk.flightaware.com/live/flight/{{ full_flight_number | replace("U2", "EZY") }}">FlightAware</a>
| <a href="{{ radarbox_url }}">radarbox</a>
</div>
<div class="grid-item text-end">
{% if item.distance %}
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
{% endif %}
</div>
{% endfor %}
{% endmacro %}
{% macro flight_row(item) %}
{% set full_flight_number = item.airline + item.flight_number %}
{% set radarbox_url = "https://www.radarbox.com/data/flights/" + full_flight_number %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">{{ item.from }} &rarr; {{ item.to }}</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ item.duration }}</div>
<div class="grid-item">{{ full_flight_number }}</div>
<div class="grid-item">
{% if g.user.is_authenticated %}
{{ item.booking_reference }}
{% else %}
<em>redacted</em>
{% endif %}
</div>
<div class="grid-item">
<a href="https://www.flightradar24.com/data/flights/{{ full_flight_number | lower }}">flightradar24</a>
| <a href="https://uk.flightaware.com/live/flight/{{ full_flight_number | replace("U2", "EZY") }}">FlightAware</a>
| <a href="{{ radarbox_url }}">radarbox</a>
</div>
<div class="grid-item text-end">
{% if item.distance %}
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
{% endif %}
</div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(item.price) }} {{ item.currency }}</span>
{% if item.currency != "GBP" %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{% endmacro %}
{% macro train_row(item) %}
{% set url = item.url %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">
{% if g.user.is_authenticated and item.url %}<a href="{{ url }}">{% endif %}
{{ item.from }} &rarr; {{ item.to }}
{% if g.user.is_authenticated and item.url %}</a>{% endif %}
</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.depart != item.arrive and item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">
{% if g.user.is_authenticated %}
{{ item.booking_reference }}
{% else %}
<em>redacted</em>
{% endif %}
</div>
<div class="grid-item">
{% for leg in item.legs %}
{% if leg.url %}
<a href="{{ leg.url }}">[{{ loop.index }}]</a>
{% endif %}
{% endfor %}
</div>
<div class="grid-item text-end">
{% if item.distance %}
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
{% endif %}
</div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(item.price) }} {{ item.currency }}</span>
{% if item.currency != "GBP" %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{% endmacro %}
{% macro ferry_row(item) %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">
{{ item.from }} &rarr; {{ item.to }}
</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.depart != item.arrive and item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item"></div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item"></div>
<div class="grid-item"></div>
<div class="grid-item"></div>
<div class="grid-item text-end">
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">{{ "{:,f}".format(item.price) }} {{ item.currency }}</span>
{% if item.currency != "GBP" %}
<span class="badge bg-info text-nowrap">{{ "{:,.2f}".format(item.price / fx_rate[item.currency]) }} GBP</span>
{% endif %}
{% endif %}
</div>
{# <div class="grid-item">{{ item | pprint }}</div> #}
{% endmacro %}

View file

@ -2,13 +2,24 @@
{% set pages = [
{"endpoint": "index", "label": "Home" },
{"endpoint": "conference_list", "label": "Conference" },
{"endpoint": "recent", "label": "Recent" },
{"endpoint": "calendar_page", "label": "Calendar" },
{"endpoint": "trip_future_list", "label": "Future trips" },
{"endpoint": "trip_past_list", "label": "Past trips" },
{"endpoint": "conference_list", "label": "Conferences" },
{"endpoint": "past_conference_list", "label": "Past conferences" },
{"endpoint": "travel_list", "label": "Travel" },
{"endpoint": "accommodation_list", "label": "Accommodation" },
{"endpoint": "gaps_page", "label": "Gaps" },
] %}
{"endpoint": "weekends", "label": "Weekends" },
{"endpoint": "launch_list", "label": "Space launches" },
{"endpoint": "holiday_list", "label": "Holidays" },
] + ([{"endpoint": "birthday_list", "label": "Birthdays" }]
if g.user.is_authenticated else [])
%}
<nav class="navbar navbar-expand-lg bg-success" data-bs-theme="dark">
<nav class="navbar navbar-expand-md bg-success" data-bs-theme="dark">
<div class="container-fluid">
<a class="navbar-brand" href="{{ url_for("index") }}">Agenda</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
@ -17,11 +28,21 @@
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% for page in pages %}
{% set is_active = request.endpoint == page.endpoint %}
<li class="nav-item">
<a class="nav-link{% if request.endpoint == page.endpoint %} border border-white border-2 active{% endif %}" href="{{ url_for(page.endpoint) }}">{{ page.label }}</a>
<a class="nav-link{% if is_active %} border border-white border-2 active{% endif %}" {% if is_active %} aria-current="page"{% endif %} href="{{ url_for(page.endpoint) }}">
{{ page.label }}
</a>
</li>
{% endfor %}
</ul>
<ul class="navbar-nav ms-auto">
{% if g.user.is_authenticated %}
<li class="nav-item"><a class="nav-link" href="{{ url_for("logout", next=request.url) }}">Logout</a></li>
{% else %}
<li class="nav-item"><a class="nav-link" href="{{ url_for("login", next=request.url) }}">Login</a></li>
{% endif %}
</ul>
</div>
</div>
</nav>

View file

@ -1,5 +1,7 @@
{% extends "base.html" %}
{% block title %}Agenda error - Edward Betts{% endblock %}
{% block style %}
<link rel="stylesheet" href="{{url_for('static', filename='css/exception.css')}}" />
{% endblock %}

View file

@ -1,23 +1,23 @@
{% extends "base.html" %}
{% from "macros.html" import flight_booking_row, train_row with context %}
{% block travel %}
{% endblock %}
{% block title %}Travel - Edward Betts{% endblock %}
{% macro display_datetime(dt) %}{{ dt.strftime("%a, %d, %b %Y %H:%M %z") }}{% endmacro %}
{% macro display_time(dt) %}{{ dt.strftime("%H:%M %z") }}{% endmacro %}
{% set flight_column_count = 10 %}
{% set column_count = 10 %}
{% block style %}
<style>
.grid-container {
display: grid;
grid-template-columns: repeat(7, auto); /* 7 columns for each piece of information */
grid-template-columns: repeat({{ flight_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.train-grid-container {
display: grid;
grid-template-columns: repeat(6, auto); /* 7 columns for each piece of information */
grid-template-columns: repeat({{ column_count }}, auto);
gap: 10px;
justify-content: start;
}
@ -37,27 +37,19 @@
<h3>flights</h3>
<div class="grid-container">
<div class="grid-item">reference</div>
<div class="grid-item">price</div>
<div class="grid-item text-end">date</div>
<div class="grid-item">route</div>
<div class="grid-item">take-off</div>
<div class="grid-item">land</div>
<div class="grid-item">duration</div>
<div class="grid-item">flight</div>
<div class="grid-item">reference</div>
<div class="grid-item">tracking</div>
<div class="grid-item">distance</div>
{% for item in flights | sort(attribute="depart") if item.arrive %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">{{ item.from }} &rarr; {{ item.to }}</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ item.duration }}</div>
<div class="grid-item">{{ item.airline }}{{ item.flight_number }}</div>
<div class="grid-item">{{ item.booking_reference }}</div>
{% for item in flights %}
{{ flight_booking_row(item) }}
{% endfor %}
</div>
@ -68,21 +60,15 @@
<div class="grid-item">route</div>
<div class="grid-item">depart</div>
<div class="grid-item">arrive</div>
<div class="grid-item">duration</div>
<div class="grid-item">operator</div>
<div class="grid-item">reference</div>
<div class="grid-item"></div>
<div class="grid-item"></div>
<div class="grid-item"></div>
{% for item in trains | sort(attribute="depart") if item.arrive %}
<div class="grid-item text-end">{{ item.depart.strftime("%a, %d %b %Y") }}</div>
<div class="grid-item">{{ item.from }} &rarr; {{ item.to }}</div>
<div class="grid-item">{{ item.depart.strftime("%H:%M") }}</div>
<div class="grid-item">
{% if item.arrive %}
{{ item.arrive.strftime("%H:%M") }}
{% if item.arrive.date() != item.depart.date() %}+1 day{% endif %}
{% endif %}
</div>
<div class="grid-item">{{ item.operator }}</div>
<div class="grid-item">{{ item.booking_reference }}</div>
{% for item in trains | sort(attribute="depart") %}
{{ train_row(item) }}
{% endfor %}
</div>

218
templates/trip/list.html Normal file
View file

@ -0,0 +1,218 @@
{% extends "base.html" %}
{% from "macros.html" import trip_link, display_date_no_year, display_date, display_datetime, display_time, format_distance with context %}
{% block title %}{{ heading }} - Edward Betts{% endblock %}
{% block style %}
<link rel="stylesheet" href="{{ url_for("static", filename="leaflet/leaflet.css") }}">
<style>
body, html {
height: 100%;
margin: 0;
}
.container-fluid {
height: calc(100% - 56px); /* Subtracting the height of the navbar */
}
.text-content {
overflow-y: scroll;
height: 100%;
}
.map-container {
position: sticky;
top: 56px; /* Adjust to be below the navbar */
height: calc(100vh - 56px); /* Subtracting the height of the navbar */
}
#map {
height: 100%;
}
@media (max-width: 767.98px) {
.container-fluid {
display: block;
height: auto;
}
.map-container {
position: relative;
top: 0;
height: 50vh; /* Adjust as needed */
}
.text-content {
height: auto;
overflow-y: auto;
}
}
</style>
{% endblock %}
{% macro section(heading, item_list) %}
{% set items = item_list | list %}
<div class="heading"><h2>{{ heading }}</h2></div>
<p><a href="{{ url_for("trip_stats") }}">Trip statistics</a></p>
<p>{{ items | count }} trips</p>
{% if item_list %}
<div>Total distance: {{ format_distance(total_distance) }}</div>
{% for transport_type, distance in distances_by_transport_type %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
</div>
{% endfor %}
{% for trip in items %}
{% set distances_by_transport_type = trip.distances_by_transport_type() %}
{% set total_distance = trip.total_distance() %}
{% set end = trip.end %}
<div class="border border-2 rounded mb-2 p-2">
<h3>
{{ trip_link(trip) }}
<small class="text-muted">({{ display_date(trip.start) }})</small></h3>
<ul class="list-unstyled">
{% for c in trip.countries %}
<li>
{{ c.name }}
{{ c.flag }}
</li>
{% endfor %}
</ul>
{% if end %}
<div>Dates: {{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}</div>
{% else %}
<div>Start: {{ display_date_no_year(trip.start) }} (end date missing)</div>
{% endif %}
{% if total_distance %}
<div>
Total distance:
{{ format_distance(total_distance) }}
</div>
{% endif %}
{% if distances_by_transport_type %}
{% for transport_type, distance in distances_by_transport_type %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
</div>
{% endfor %}
{% endif %}
{{ conference_list(trip) }}
{% for day, elements in trip.elements_grouped_by_day() %}
<h4>{{ display_date_no_year(day) }}</h4>
{% set accommodation_label = {"check-in": "check-in from", "check-out": "check-out by"} %}
{% for e in elements %}
{% if e.element_type in accommodation_label %}
{% set c = get_country(e.detail.country) %}
<div>
{{ e.get_emoji() }} {{ e.title }} {{ c.flag }}
({{ accommodation_label[e.element_type] }} {{ display_time(e.start_time) }})
</div>
{% else %}
<div>
{{ e.get_emoji() }}
{{ display_time(e.start_time) }}
&ndash;
{{ e.start_loc }} {{ e.start_country.flag }}
{{ display_time(e.end_time) }}
&ndash;
{{ e.end_loc }} {{ e.end_country.flag }}
{% if e.element_type == "flight" %}
{% set full_flight_number = e.detail.airline + e.detail.flight_number %}
{% set radarbox_url = "https://www.radarbox.com/data/flights/" + full_flight_number %}
<span class="text-nowrap"><strong>airline:</strong> {{ e.detail.airline_name }}</span>
<span class="text-nowrap"><strong>flight number:</strong> {{ e.detail.airline }}{{ e.detail.flight_number }}</span>
{% if e.detail.duration %}
<span class="text-nowrap"><strong>duration:</strong> {{ e.detail.duration }}</span>
{% endif %}
{# <pre>{{ e.detail | pprint }}</pre> #}
{% endif %}
{% if e.detail.distance %}
<span class="text-nowrap"><strong>distance:</strong> {{ format_distance(e.detail.distance) }}</span>
{% endif %}
{% if e.element_type == "flight" %}
<a href="https://www.flightradar24.com/data/flights/{{ full_flight_number | lower }}">flightradar24</a>
| <a href="https://uk.flightaware.com/live/flight/{{ full_flight_number | replace("U2", "EZY") }}">FlightAware</a>
| <a href="{{ radarbox_url }}">radarbox</a>
{% endif %}
</div>
{% endif %}
{% endfor %}
{% endfor %}
</div>
{% endfor %}
{% endif %}
{% endmacro %}
{% macro conference_list(trip) %}
{% for item in trip.conferences %}
{% set country = get_country(item.country) if item.country else None %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.name }}</a>
<small class="text-muted">
{{ display_date_no_year(item.start) }} to {{ display_date_no_year(item.end) }}
</small>
</h5>
<p class="card-text">
Topic: {{ item.topic }}
| Venue: {{ item.venue }}
| Location: {{ item.location }}
{% if country %}
{{ country.flag }}
{% elif item.online %}
💻 Online
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if item.free %}
| <span class="badge bg-success text-nowrap">free to attend</span>
{% elif item.price and item.currency %}
| <span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% endmacro %}
{% block content %}
<div class="container-fluid d-flex flex-column flex-md-row">
<div class="map-container col-12 col-md-6 order-1 order-md-2">
<div id="map" class="map"></div>
</div>
<div class="text-content col-12 col-md-6 order-2 order-md-1 pe-3">
{{ section(heading, trips) }}
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for("static", filename="leaflet/leaflet.js") }}"></script>
<script src="{{ url_for("static", filename="leaflet-geodesic/leaflet.geodesic.umd.min.js") }}"></script>
<script src="{{ url_for("static", filename="js/map.js") }}"></script>
<script>
var coordinates = {{ coordinates | tojson }};
var routes = {{ routes | tojson }};
build_map("map", coordinates, routes);
</script>
{% endblock %}

44
templates/trip/stats.html Normal file
View file

@ -0,0 +1,44 @@
{% extends "base.html" %}
{% from "macros.html" import format_distance with context %}
{% set heading = "Trip statistics" %}
{% block title %}{{ heading }} - Edward Betts{% endblock %}
{% block content %}
<div class="container-fluid">
<h1>Trip statistics</h1>
<div>Trips: {{ count }}</div>
<div>Conferences: {{ conferences }}</div>
<div>Total distance: {{ format_distance(total_distance) }}</div>
{% for transport_type, distance in distances_by_transport_type %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
</div>
{% endfor %}
{% for year, year_stats in yearly_stats | dictsort %}
{% set countries = year_stats.countries | sort(attribute="name") %}
<h4>{{ year }}</h4>
<div>Trips in {{ year }}: {{ year_stats.count }}</div>
<div>Conferences in {{ year }}: {{ year_stats.conferences }}</div>
<div>{{ countries | count }} countries visited in {{ year }}:
{% for c in countries %}
<span class="text-nowrap">{{ c.flag }} {{ c.name }}</span>
{% endfor %}
</div>
<div>Flights in {{ year }}: {{ year_stats.flight_count }}</div>
<div>Trains in {{ year }}: {{ year_stats.train_count }}</div>
<div>Total distance in {{ year}}: {{ format_distance(year_stats.total_distance) }}</div>
{% for transport_type, distance in year_stats.distances_by_transport_type.items() %}
<div>
{{ transport_type | title }}
distance: {{format_distance(distance) }}
</div>
{% endfor %}
{% endfor %}
</div>
{% endblock %}

138
templates/trip_list.html Normal file
View file

@ -0,0 +1,138 @@
{% extends "base.html" %}
{% from "macros.html" import trip_link, display_date_no_year, display_date, conference_row, accommodation_row, flight_row, train_row, ferry_row with context %}
{% set row = { "flight": flight_row, "train": train_row, "ferry": ferry_row } %}
{% block title %}Trips - Edward Betts{% endblock %}
{% block style %}
<link rel="stylesheet" href="{{ url_for("static", filename="leaflet/leaflet.css") }}">
{% set conference_column_count = 8 %}
{% set accommodation_column_count = 8 %}
{% set travel_column_count = 10 %}
<style>
.conferences {
display: grid;
grid-template-columns: repeat({{ conference_column_count }}, auto); /* 7 columns for each piece of information */
gap: 10px;
justify-content: start;
}
.accommodation {
display: grid;
grid-template-columns: repeat({{ accommodation_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.travel {
display: grid;
grid-template-columns: repeat({{ travel_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.grid-item {
/* Additional styling for grid items can go here */
}
.map {
height: 80vh;
}
</style>
{% endblock %}
{% macro section(heading, item_list, badge) %}
{% if item_list %}
{% set items = item_list | list %}
<div class="heading"><h2>{{ heading }}</h2></div>
<p>{{ items | count }} trips</p>
{% for trip in items %}
{% set total_distance = trip.total_distance() %}
{% set end = trip.end %}
<div class="border border-2 rounded mb-2 p-2">
<h3>
{{ trip_link(trip) }}
<small class="text-muted">({{ display_date(trip.start) }})</small></h3>
<div>Countries: {{ trip.countries_str }}</div>
{% if end %}
<div>Dates: {{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}</div>
{% else %}
<div>Start: {{ display_date_no_year(trip.start) }} (end date missing)</div>
{% endif %}
{% if total_distance %}
<div>Total distance:
{{ "{:,.0f} km / {:,.0f} miles".format(total_distance, total_distance / 1.60934) }}
</div>
{% endif %}
<div class="conferences">
{% for conf in trip.conferences %}
{{ conference_row(conf, "going") }}
{% endfor %}
</div>
<div class="accommodation">
{% for conf in trip.accommodation %}
{{ accommodation_row(conf, "going") }}
{% endfor %}
</div>
<div class="travel">
{% for item in trip.travel %}
{{ row[item.type](item) }}
{% endfor %}
</div>
</div>
{% endfor %}
{% endif %}
{% endmacro %}
{% block content %}
<div class="p-2">
<h1>Trips</h1>
<div class="row">
<div class="col-1">
</div>
<div class="col-11">
<div id="future-map" class="map"></div>
</div>
{{ section("Current", current, "attending") }}
{{ section("Future", future, "going") }}
<div class="col-1">
</div>
<div class="col-11">
<div id="past-map" class="map"></div>
</div>
{{ section("Past", past|reverse, "went") }}
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for("static", filename="leaflet/leaflet.js") }}"></script>
<script src="{{ url_for("static", filename="leaflet-geodesic/leaflet.geodesic.umd.min.js") }}"></script>
<script src="{{ url_for("static", filename="js/map.js") }}"></script>
<script>
var future_coordinates = {{ future_coordinates | tojson }};
var future_routes = {{ future_routes | tojson }};
build_map("future-map", future_coordinates, future_routes);
var past_coordinates = {{ past_coordinates | tojson }};
var past_routes = {{ past_routes | tojson }};
build_map("past-map", past_coordinates, past_routes);
</script>
{% endblock %}

View file

@ -0,0 +1,67 @@
{% extends "base.html" %}
{% from "macros.html" import trip_link, display_date_no_year, display_date, conference_row, accommodation_row, flight_row, train_row with context %}
{% set row = { "flight": flight_row, "train": train_row } %}
{% block style %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
{% set conference_column_count = 7 %}
{% set accommodation_column_count = 7 %}
{% set travel_column_count = 8 %}
<style>
.conferences {
display: grid;
grid-template-columns: repeat({{ conference_column_count }}, auto); /* 7 columns for each piece of information */
gap: 10px;
justify-content: start;
}
.accommodation {
display: grid;
grid-template-columns: repeat({{ accommodation_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.travel {
display: grid;
grid-template-columns: repeat({{ travel_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.grid-item {
/* Additional styling for grid items can go here */
}
.map {
height: 80vh;
}
</style>
{% endblock %}
{% block content %}
<div class="p-2">
<h1>Trips</h1>
<p>{{ future | count }} trips</p>
{% for trip in future %}
{% set end = trip.end %}
<div>
{{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}:
{{ trip.title }} &mdash; {{ trip.locations_str }}
</div>
{% endfor %}
</div>
{% endblock %}

318
templates/trip_page.html Normal file
View file

@ -0,0 +1,318 @@
{% extends "base.html" %}
{% block title %}{{ trip.title }} ({{ display_date(trip.start) }}) - Edward Betts{% endblock %}
{% from "macros.html" import trip_link, display_datetime, display_date_no_year, display_date, conference_row, accommodation_row, flight_row, train_row with context %}
{% set row = { "flight": flight_row, "train": train_row } %}
{% macro next_and_previous() %}
<p>
{% if prev_trip %}
previous: {{ trip_link(prev_trip) }} ({{ (trip.start - prev_trip.end).days }} days)
{% endif %}
{% if next_trip %}
next: {{ trip_link(next_trip) }} ({{ (next_trip.start - trip.end).days }} days)
{% endif %}
</p>
{% endmacro %}
{% block style %}
{% if coordinates %}
<link rel="stylesheet" href="{{ url_for("static", filename="leaflet/leaflet.css") }}">
{% endif %}
{% set conference_column_count = 7 %}
{% set accommodation_column_count = 7 %}
{% set travel_column_count = 9 %}
<style>
.conferences {
display: grid;
grid-template-columns: repeat({{ conference_column_count }}, auto); /* 7 columns for each piece of information */
gap: 10px;
justify-content: start;
}
.accommodation {
display: grid;
grid-template-columns: repeat({{ accommodation_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.travel {
display: grid;
grid-template-columns: repeat({{ travel_column_count }}, auto);
gap: 10px;
justify-content: start;
}
.grid-item {
/* Additional styling for grid items can go here */
}
#map {
height: 90vh;
}
</style>
{% endblock %}
{% set end = trip.end %}
{% set total_distance = trip.total_distance() %}
{% set distances_by_transport_type = trip.distances_by_transport_type() %}
{% block content %}
<div class="row">
<div class="col-md-6 col-sm-12">
<div class="m-3">
{{ next_and_previous() }}
<h1>{{ trip.title }}</h1>
<p class="lead">
{% if end %}
{{ display_date_no_year(trip.start) }} to {{ display_date_no_year(end) }}
({{ (end - trip.start).days }} nights)
{% else %}
{{ display_date_no_year(trip.start) }} (end date missing)
{% endif %}
</p>
<div class="mb-3">
{# <div>Countries: {{ trip.countries_str }}</div> #}
<div>Locations: {{ trip.locations_str }}</div>
{% if total_distance %}
<div>Total distance:
{{ "{:,.0f} km / {:,.0f} miles".format(total_distance, total_distance / 1.60934) }}
</div>
{% endif %}
{% if distances_by_transport_type %}
{% for transport_type, distance in distances_by_transport_type %}
<div>{{ transport_type | title }} distance:
{{ "{:,.0f} km / {:,.0f} miles".format(distance, distance / 1.60934) }}
</div>
{% endfor %}
{% endif %}
{% set delta = human_readable_delta(trip.start) %}
{% if delta %}
<div>How long until trip: {{ delta }}</div>
{% endif %}
</div>
{% for item in trip.conferences %}
{% set country = get_country(item.country) if item.country else None %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.name }}</a>
<small class="text-muted">
{{ display_date_no_year(item.start) }} to {{ display_date_no_year(item.end) }}
</small>
</h5>
<p class="card-text">
<strong>Topic:</strong> {{ item.topic }}
<strong>Venue:</strong> {{ item.venue }}
<strong>Location:</strong> {{ item.location }}
{% if country %}
{{ country.flag }}
{% elif item.online %}
💻 Online
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if item.free %}
<span class="badge bg-success text-nowrap">free to attend</span>
{% elif item.price and item.currency %}
<span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% for item in trip.accommodation %}
{% set country = get_country(item.country) if item.country else None %}
{% set nights = (item.to.date() - item.from.date()).days %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
{% if item.operator %}{{ item.operator }}: {% endif %}
<a href="{{ item.url }}">{{ item.name }}</a>
<small class="text-muted">
{{ display_date_no_year(item.from) }} to {{ display_date_no_year(item.to) }}
({% if nights == 1 %}1 night{% else %}{{ nights }} nights{% endif %})
</small>
</h5>
<p class="card-text">
<strong>Address:</strong> {{ item.address }}
<strong>Location:</strong> {{ item.location }}
{% if country %}
{{ country.flag }}
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% if trip.flight_bookings %}
<h3>Flight bookings</h3>
{% for item in trip.flight_bookings %}
<div>
{{ item.flights | map(attribute="airline_name") | unique | join(" + ") }}
{% if g.user.is_authenticated and item.booking_reference %}
<strong>booking reference:</strong> {{ item.booking_reference }}
{% endif %}
{% if g.user.is_authenticated and item.price and item.currency %}
<span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</div>
{% endfor %}
{% endif %}
{% for item in trip.events %}
{% set country = get_country(item.country) if item.country else None %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
<a href="{{ item.url }}">{{ item.title }}</a>
<small class="text-muted">{{ display_date_no_year(item.date) }}</small>
</h5>
<p class="card-text">
Address: {{ item.address }}
| Location: {{ item.location }}
{% if country %}
{{ country.flag }}
{% else %}
<span class="text-bg-danger p-2">
country code <strong>{{ item.country }}</strong> not found
</span>
{% endif %}
{% if g.user.is_authenticated and item.price and item.currency %}
| <span class="badge bg-info text-nowrap">price: {{ item.price }} {{ item.currency }}</span>
{% endif %}
</p>
</div>
</div>
{% endfor %}
{% for item in trip.travel %}
<div class="card my-1">
<div class="card-body">
<h5 class="card-title">
{% if item.type == "flight" %}
✈️
{{ item.from_airport.name }} ({{ item.from_airport.iata}})
&rarr;
{{ item.to_airport.name }} ({{item.to_airport.iata}})
{% elif item.type == "train" %}
🚆
{{ item.from }}
&rarr;
{{ item.to }}
{% endif %}
</h5>
<p class="card-text">
{% if item.type == "flight" %}
<div>
<span>{{ item.airline_name }} ({{ item.airline }})</span>
{{ display_datetime(item.depart) }}
{% if item.arrive %}
&rarr;
{{ item.arrive.strftime("%H:%M %z") }}
<span>🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</span>
{% endif %}
<span>{{ item.airline }}{{ item.flight_number }}</span>
{% if item.distance %}
<span>
🌍distance:
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
</span>
{% endif %}
</div>
{% elif item.type == "train" %}
<div>
{{ display_datetime(item.depart) }}
&rarr;
{{ item.arrive.strftime("%H:%M %z") }}
{% if item.class %}
<span class="badge bg-info text-nowrap">{{ item.class }}</span>
{% endif %}
<span>🕒{{ ((item.arrive - item.depart).total_seconds() // 60) | int }} mins</span>
{% if item.distance %}
<span>
🛤️
{{ "{:,.0f} km / {:,.0f} miles".format(item.distance, item.distance / 1.60934) }}
</span>
{% endif %}
</div>
{% endif %}
</p>
</div>
</div>
{% endfor %}
<div class="mt-3">
<h4>Holidays</h4>
{% if holidays %}
<table class="table table-hover w-auto">
{% for item in holidays %}
{% set country = get_country(item.country) %}
<tr>
{% if loop.first or item.date != loop.previtem.date %}
<td class="text-end">{{ display_date(item.date) }}</td>
{% else %}
<td></td>
{% endif %}
<td>{{ country.flag }} {{ country.name }}</td>
<td>{{ item.display_name }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<p>No public holidays during trip.</p>
{% endif %}
</div>
{{ next_and_previous() }}
</div>
</div>
<div class="col-md-6 col-sm-12">
<div id="map"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="{{ url_for("static", filename="leaflet/leaflet.js") }}"></script>
<script src="{{ url_for("static", filename="leaflet-geodesic/leaflet.geodesic.umd.min.js") }}"></script>
<script src="{{ url_for("static", filename="js/map.js") }}"></script>
<script>
var coordinates = {{ coordinates | tojson }};
var routes = {{ routes | tojson }};
build_map("map", coordinates, routes);
</script>
{% endblock %}

45
templates/weekends.html Normal file
View file

@ -0,0 +1,45 @@
{% extends "base.html" %}
{% block title %}Weekends - Edward Betts{% endblock %}
{% block content %}
<div class="p-2">
<h1>Weekends</h1>
<table class="table table-hover w-auto">
<thead>
<tr>
<th class="text-end">Week</th>
<th class="text-end">Date</th>
<th>Saturday</th>
<th>Sunday</th>
</tr>
</thead>
<tbody>
{% for weekend in items %}
<tr>
<td class="text-end">
{{ weekend.date.isocalendar().week }}
</td>
<td class="text-end text-nowrap">
{{ weekend.date.strftime("%-d %b %Y") }}
</td>
{% for day in "saturday", "sunday" %}
<td>
{% if weekend[day] %}
{% for event in weekend[day] %}
<a href="{{ event.url }}">{{ event.title }}</a>{% if not loop.last %},{%endif %}
{% endfor %}
{% else %}
<strong>free</strong>
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}

View file

@ -1,3 +1,5 @@
"""Tests for agenda."""
import datetime
from decimal import Decimal
@ -15,63 +17,63 @@ from agenda import (
@pytest.fixture
def mock_today():
# Mock the current date for testing purposes
def mock_today() -> datetime.date:
"""Mock the current date for testing purposes."""
return datetime.date(2023, 10, 5)
@pytest.fixture
def mock_now():
# Mock the current date and time for testing purposes
def mock_now() -> datetime.datetime:
"""Mock the current date and time for testing purposes."""
return datetime.datetime(2023, 10, 5, 12, 0, 0)
def test_next_uk_mothers_day(mock_today):
# Test next_uk_mothers_day function
def test_next_uk_mothers_day(mock_today: datetime.date) -> None:
"""Test next_uk_mothers_day function."""
next_mothers_day = next_uk_mothers_day(mock_today)
assert next_mothers_day == datetime.date(2024, 4, 21)
def test_next_uk_fathers_day(mock_today):
# Test next_uk_fathers_day function
def test_next_uk_fathers_day(mock_today: datetime.date) -> None:
"""Test next_uk_fathers_day function."""
next_fathers_day = next_uk_fathers_day(mock_today)
assert next_fathers_day == datetime.date(2024, 6, 21)
def test_get_next_timezone_transition(mock_now) -> None:
# Test get_next_timezone_transition function
def test_get_next_timezone_transition(mock_now: datetime.date) -> None:
"""Test get_next_timezone_transition function."""
next_transition = get_next_timezone_transition(mock_now, "Europe/London")
assert next_transition == datetime.date(2023, 10, 29)
def test_get_next_bank_holiday(mock_today) -> None:
# Test get_next_bank_holiday function
def test_get_next_bank_holiday(mock_today: datetime.date) -> None:
"""Test get_next_bank_holiday function."""
next_holiday = get_next_bank_holiday(mock_today)[0]
assert next_holiday.date == datetime.date(2023, 12, 25)
assert next_holiday.title == "Christmas Day"
def test_get_gbpusd(mock_now):
# Test get_gbpusd function
def test_get_gbpusd(mock_now: datetime.datetime) -> None:
"""Test get_gbpusd function."""
gbpusd = get_gbpusd()
assert isinstance(gbpusd, Decimal)
# You can add more assertions based on your specific use case.
def test_next_economist(mock_today):
# Test next_economist function
def test_next_economist(mock_today: datetime.date) -> None:
"""Test next_economist function."""
next_publication = next_economist(mock_today)
assert next_publication == datetime.date(2023, 10, 5)
def test_uk_financial_year_end():
# Test uk_financial_year_end function
def test_uk_financial_year_end() -> None:
"""Test uk_financial_year_end function."""
financial_year_end = uk_financial_year_end(datetime.date(2023, 4, 1))
assert financial_year_end == datetime.date(2023, 4, 5)
def test_timedelta_display():
# Test timedelta_display function
def test_timedelta_display() -> None:
"""Test timedelta_display function."""
delta = datetime.timedelta(days=2, hours=5, minutes=30)
display = timedelta_display(delta)
assert display == " 2 days 5 hrs 30 mins"

96
tests/test_utils.py Normal file
View file

@ -0,0 +1,96 @@
"""Test utility functions."""
from datetime import date, datetime, timedelta, timezone
import pytest
from agenda.utils import as_date, as_datetime, human_readable_delta
from freezegun import freeze_time
def test_as_date_with_datetime() -> None:
"""Test converting a datetime object to a date."""
dt = datetime(2024, 7, 7, 12, 0, 0, tzinfo=timezone.utc)
result = as_date(dt)
assert result == date(2024, 7, 7)
def test_as_date_with_date() -> None:
"""Test passing a date object through as_date."""
d = date(2024, 7, 7)
result = as_date(d)
assert result == d
def test_as_date_with_invalid_type() -> None:
"""Test as_date with an invalid type, expecting a TypeError."""
with pytest.raises(TypeError):
as_date("2024-07-07")
def test_as_datetime_with_datetime() -> None:
"""Test passing a datetime object through as_datetime."""
dt = datetime(2024, 7, 7, 12, 0, 0, tzinfo=timezone.utc)
result = as_datetime(dt)
assert result == dt
def test_as_datetime_with_date() -> None:
"""Test converting a date object to a datetime."""
d = date(2024, 7, 7)
result = as_datetime(d)
expected = datetime(2024, 7, 7, 0, 0, 0, tzinfo=timezone.utc)
assert result == expected
def test_as_datetime_with_invalid_type() -> None:
"""Test as_datetime with an invalid type, expecting a TypeError."""
with pytest.raises(TypeError):
as_datetime("2024-07-07")
@freeze_time("2024-07-01")
def test_human_readable_delta_future_date() -> None:
"""Test human_readable_delta with a future date 45 days from today."""
future_date = date.today() + timedelta(days=45)
result = human_readable_delta(future_date)
assert result == "1 month 2 weeks 1 day"
@freeze_time("2024-07-01")
def test_human_readable_delta_today() -> None:
"""Test human_readable_delta with today's date, expecting None."""
today = date.today()
result = human_readable_delta(today)
assert result is None
@freeze_time("2024-07-01")
def test_human_readable_delta_past_date() -> None:
"""Test human_readable_delta with a past date, expecting None."""
past_date = date.today() - timedelta(days=1)
result = human_readable_delta(past_date)
assert result is None
@freeze_time("2024-07-01")
def test_human_readable_delta_months_only() -> None:
"""Test human_readable_delta with a future date 60 days from today."""
future_date = date.today() + timedelta(days=60)
result = human_readable_delta(future_date)
assert result == "2 months"
@freeze_time("2024-07-01")
def test_human_readable_delta_weeks_only() -> None:
"""Test human_readable_delta with a future date 14 days from today."""
future_date = date.today() + timedelta(days=14)
result = human_readable_delta(future_date)
assert result == "2 weeks"
@freeze_time("2024-07-01")
def test_human_readable_delta_days_only() -> None:
"""Test human_readable_delta with a future date 3 days from today."""
future_date = date.today() + timedelta(days=3)
result = human_readable_delta(future_date)
assert result == "3 days"

177
update.py Executable file
View file

@ -0,0 +1,177 @@
#!/usr/bin/python3
"""Combined update script for various data sources."""
import asyncio
import os
import sys
import typing
from datetime import date, datetime
from pprint import pformat
from time import time
import deepdiff # type: ignore
import flask
import requests
import agenda.bristol_waste
import agenda.fx
import agenda.geomob
import agenda.gwr
import agenda.mail
import agenda.thespacedevs
import agenda.types
import agenda.uk_holiday
from agenda.types import StrDict
from web_view import app
async def update_bank_holidays(config: flask.config.Config) -> None:
"""Update cached copy of UK Bank holidays."""
t0 = time()
events = await agenda.uk_holiday.get_holiday_list(config["DATA_DIR"])
time_taken = time() - t0
if not sys.stdin.isatty():
return
print(len(events), "bank holidays in list")
print(f"took {time_taken:.1f} seconds")
async def update_bristol_bins(config: flask.config.Config) -> None:
"""Update waste schedule from Bristol City Council."""
t0 = time()
events = await agenda.bristol_waste.get(
date.today(), config["DATA_DIR"], config["BRISTOL_UPRN"], refresh=True
)
time_taken = time() - t0
if not sys.stdin.isatty():
return
for event in events:
print(event)
print(f"took {time_taken:.1f} seconds")
def update_gwr_advance_ticket_date(config: flask.config.Config) -> None:
"""Update GWR advance ticket date cache."""
filename = os.path.join(config["DATA_DIR"], "advance-tickets.html")
existing_html = open(filename).read()
existing_date = agenda.gwr.extract_weekday_date(existing_html)
new_html = requests.get(agenda.gwr.url).text
open(filename, "w").write(new_html)
new_date = agenda.gwr.extract_weekday_date(new_html)
if existing_date == new_date:
if sys.stdin.isatty():
print("date has't changed:", existing_date)
return
subject = f"New GWR advance ticket booking date: {new_date}"
body = f"""Old date: {existing_date}
New date: {new_date}
{agenda.gwr.url}
Agenda: https://edwardbetts.com/agenda/
"""
agenda.mail.send_mail(config, subject, body)
def report_space_launch_change(
config: flask.config.Config, prev_launch: StrDict | None, cur_launch: StrDict | None
) -> None:
"""Send mail to announce change to space launch data."""
if cur_launch:
name = cur_launch["name"]
else:
assert prev_launch
name = prev_launch["name"]
subject = f"Change to {name}"
differences = deepdiff.DeepDiff(prev_launch, cur_launch)
body = f"""
A space launch of interest was updated.
{pformat(differences)}
"""
agenda.mail.send_mail(config, subject, body)
def get_launch_by_slug(data: StrDict, slug: str) -> StrDict | None:
"""Find last update for space launch."""
return {item["slug"]: typing.cast(StrDict, item) for item in data["results"]}.get(
slug
)
def update_thespacedevs(config: flask.config.Config) -> None:
"""Update cache of space launch API."""
rocket_dir = os.path.join(config["DATA_DIR"], "thespacedevs")
existing_data = agenda.thespacedevs.load_cached_launches(rocket_dir)
assert existing_data
prev_launches = {
slug: get_launch_by_slug(existing_data, slug)
for slug in config["FOLLOW_LAUNCHES"]
}
t0 = time()
data = agenda.thespacedevs.next_launch_api_data(rocket_dir)
if not data:
return # thespacedevs API call failed
cur_launches = {
slug: get_launch_by_slug(data, slug) for slug in config["FOLLOW_LAUNCHES"]
}
for slug in config["FOLLOW_LAUNCHES"]:
prev, cur = prev_launches[slug], cur_launches[slug]
if prev is None and cur is None:
continue
if prev and cur and prev["last_updated"] == cur["last_updated"]:
continue
report_space_launch_change(config, prev, cur)
time_taken = time() - t0
if not sys.stdin.isatty():
return
rockets = [agenda.thespacedevs.summarize_launch(item) for item in data["results"]]
print(len(rockets), "launches")
print(f"took {time_taken:.1f} seconds")
def update_gandi(config: flask.config.Config) -> None:
"""Retrieve list of domains from gandi.net."""
url = "https://api.gandi.net/v5/domain/domains"
headers = {"authorization": "Bearer " + config["GANDI_TOKEN"]}
filename = os.path.join(config["DATA_DIR"], "gandi_domains.json")
r = requests.request("GET", url, headers=headers)
items = r.json()
assert isinstance(items, list)
assert all(item["fqdn"] and item["dates"]["registry_ends_at"] for item in items)
with open(filename, "w") as out:
out.write(r.text)
def main() -> None:
"""Update caches."""
now = datetime.now()
hour = now.hour
with app.app_context():
if hour % 3 == 0:
asyncio.run(update_bank_holidays(app.config))
asyncio.run(update_bristol_bins(app.config))
update_gwr_advance_ticket_date(app.config)
update_gandi(app.config)
agenda.geomob.update(app.config)
agenda.fx.get_rates(app.config)
update_thespacedevs(app.config)
if __name__ == "__main__":
main()

View file

@ -1,62 +0,0 @@
#!/usr/bin/python3
"""Update GWR advance ticket date cache."""
import os.path
import smtplib
import sys
from email.message import EmailMessage
from email.utils import formatdate, make_msgid
import requests
from agenda import gwr
config = __import__("config.default", fromlist=[""])
def send_mail(subject: str, body: str) -> None:
"""Send an e-mail."""
msg = EmailMessage()
msg["Subject"] = subject
msg["To"] = f"{config.NAME} <{config.MAIL_TO}>"
msg["From"] = f"{config.NAME} <{config.MAIL_FROM}>"
msg["Date"] = formatdate()
msg["Message-ID"] = make_msgid()
msg.set_content(body)
s = smtplib.SMTP(config.SMTP_HOST)
s.sendmail(config.MAIL_TO, [config.MAIL_TO], msg.as_string())
s.quit()
def main() -> None:
"""Get date from web page and compare with existing."""
filename = os.path.join(config.DATA_DIR, "advance-tickets.html")
existing_html = open(filename).read()
existing_date = gwr.extract_weekday_date(existing_html)
new_html = requests.get(gwr.url).text
open(filename, "w").write(new_html)
new_date = gwr.extract_weekday_date(new_html)
if existing_date == new_date:
if sys.stdin.isatty():
print("date has't changed:", existing_date)
return
subject = f"New GWR advance ticket booking date: {new_date}"
body = f"""Old date: {existing_date}
New date: {new_date}
{gwr.url}
Agenda: https://edwardbetts.com/agenda/
"""
send_mail(subject, body)
if __name__ == "__main__":
main()

98
validate_yaml.py Executable file
View file

@ -0,0 +1,98 @@
#!/usr/bin/python3
"""Load YAML data to ensure validity."""
import os
import typing
from datetime import date, timedelta
import agenda
import agenda.conference
import agenda.data
import agenda.travel
import agenda.trip
import agenda.types
config = __import__("config.default", fromlist=[""])
data_dir = config.PERSONAL_DATA
def check_trips() -> None:
"""Check trips."""
trip_list = agenda.trip.build_trip_list(data_dir)
print(len(trip_list), "trips")
coords, routes = agenda.trip.get_coordinates_and_routes(trip_list, data_dir)
print(len(coords), "coords")
print(len(routes), "routes")
def check_flights() -> None:
"""Check flights."""
flights = agenda.travel.parse_yaml("flights", data_dir)
print(len(flights), "flights")
def check_trains() -> None:
"""Check trains."""
trains = agenda.travel.parse_yaml("trains", data_dir)
print(len(trains), "trains")
def check_conferences() -> None:
"""Check conferences."""
conferences = agenda.conference.get_list(os.path.join(data_dir, "conferences.yaml"))
print(len(conferences), "conferences")
def check_events() -> None:
"""Check events."""
today = date.today()
last_year = today - timedelta(days=365)
next_year = today + timedelta(days=2 * 365)
events = agenda.events_yaml.read(data_dir, last_year, next_year)
print(len(events), "events")
def check_airports() -> None:
"""Check airports."""
airports = typing.cast(
dict[str, agenda.types.StrDict], agenda.travel.parse_yaml("airports", data_dir)
)
print(len(airports), "airports")
for airport in airports.values():
assert "country" in airport
assert agenda.get_country(airport["country"])
def check_stations() -> None:
"""Check stations."""
stations = agenda.travel.parse_yaml("stations", data_dir)
print(len(stations), "stations")
for station in stations:
assert "country" in station
assert agenda.get_country(station["country"])
def check_airlines() -> None:
"""Check airlines."""
airlines = agenda.travel.parse_yaml("airlines", data_dir)
print(len(airlines), "airlines")
for airline in airlines:
assert airline.keys() == {"icao", "iata", "name"}
def check() -> None:
"""Validate personal data YAML files."""
check_trips()
check_flights()
check_trains()
check_conferences()
check_events()
check_airports()
check_stations()
check_airlines()
if __name__ == "__main__":
check()

View file

@ -2,21 +2,31 @@
"""Web page to show upcoming events."""
import decimal
import inspect
import operator
import os.path
import sys
import time
import traceback
from datetime import date, datetime
from collections import defaultdict
from datetime import date, datetime, timedelta
import flask
import UniAuth.auth
import werkzeug
import werkzeug.debug.tbtools
import yaml
import agenda.data
import agenda.error_mail
import agenda.travel
import agenda.fx
import agenda.holidays
import agenda.thespacedevs
import agenda.trip
import agenda.utils
from agenda import calendar, format_list_with_ampersand, travel, uk_tz
from agenda.types import StrDict, Trip
app = flask.Flask(__name__)
app.debug = False
@ -25,6 +35,12 @@ app.config.from_object("config.default")
agenda.error_mail.setup_error_mail(app)
@app.before_request
def handle_auth() -> None:
"""Handle authentication and set global user."""
flask.g.user = UniAuth.auth.get_current_user()
@app.errorhandler(werkzeug.exceptions.InternalServerError)
def exception_handler(e: werkzeug.exceptions.InternalServerError) -> tuple[str, int]:
"""Handle exception."""
@ -52,62 +68,255 @@ def exception_handler(e: werkzeug.exceptions.InternalServerError) -> tuple[str,
)
def get_current_trip(today: date) -> Trip | None:
"""Get current trip."""
trip_list = get_trip_list(route_distances=None)
current = [
item
for item in trip_list
if item.start <= today and (item.end or item.start) >= today
]
assert len(current) < 2
return current[0] if current else None
@app.route("/")
async def index() -> str:
"""Index page."""
t0 = time.time()
now = datetime.now()
data = await agenda.data.get_data(now, app.config)
events = data.pop("events")
markets_arg = flask.request.args.get("markets")
if markets_arg == "hide":
events = [e for e in events if e.name != "market"]
if markets_arg != "show":
agenda.data.hide_markets_while_away(events, data["accommodation_events"])
return flask.render_template(
"event_list.html",
today=now.date(),
events=events,
current_trip=get_current_trip(now.date()),
fullcalendar_events=calendar.build_events(events),
start_event_list=date.today() - timedelta(days=1),
end_event_list=date.today() + timedelta(days=365 * 2),
render_time=(time.time() - t0),
**data,
)
@app.route("/calendar")
async def calendar_page() -> str:
"""Index page."""
now = datetime.now()
data = await agenda.data.get_data(now, app.config)
return flask.render_template("index.html", today=now.date(), **data)
events = data.pop("events")
markets_arg = flask.request.args.get("markets")
if markets_arg == "hide":
events = [e for e in events if e.name != "market"]
if markets_arg != "show":
agenda.data.hide_markets_while_away(events, data["accommodation_events"])
return flask.render_template(
"calendar.html",
today=now.date(),
events=events,
fullcalendar_events=calendar.build_events(events),
**data,
)
@app.route("/recent")
async def recent() -> str:
"""Index page."""
t0 = time.time()
now = datetime.now()
data = await agenda.data.get_data(now, app.config)
events = data.pop("events")
markets_arg = flask.request.args.get("markets")
if markets_arg == "hide":
events = [e for e in events if e.name != "market"]
if markets_arg != "show":
agenda.data.hide_markets_while_away(events, data["accommodation_events"])
return flask.render_template(
"event_list.html",
today=now.date(),
events=events,
fullcalendar_events=calendar.build_events(events),
start_event_list=date.today() - timedelta(days=14),
end_event_list=date.today(),
render_time=(time.time() - t0),
**data,
)
@app.route("/launches")
def launch_list() -> str:
"""Web page showing List of space launches."""
now = datetime.now()
data_dir = app.config["DATA_DIR"]
rocket_dir = os.path.join(data_dir, "thespacedevs")
launches = agenda.thespacedevs.get_launches(rocket_dir, limit=100)
assert launches
mission_type_filter = flask.request.args.get("type")
rocket_filter = flask.request.args.get("rocket")
orbit_filter = flask.request.args.get("orbit")
mission_types = {
launch["mission"]["type"] for launch in launches if launch["mission"]
}
orbits = {
(launch["orbit"]["name"], launch["orbit"]["abbrev"])
for launch in launches
if launch.get("orbit")
}
rockets = {launch["rocket"]["full_name"] for launch in launches}
launches = [
launch
for launch in launches
if (
not mission_type_filter
or (launch["mission"] and launch["mission"]["type"] == mission_type_filter)
)
and (not rocket_filter or launch["rocket"]["full_name"] == rocket_filter)
and (
not orbit_filter
or (launch.get("orbit") and launch["orbit"]["abbrev"] == orbit_filter)
)
]
return flask.render_template(
"launches.html",
launches=launches,
rockets=rockets,
now=now,
get_country=agenda.get_country,
mission_types=mission_types,
orbits=orbits,
)
@app.route("/gaps")
async def gaps_page() -> str:
"""List of available gaps."""
now = datetime.now()
data = await agenda.data.get_data(now, app.config)
return flask.render_template("gaps.html", today=now.date(), gaps=data["gaps"])
trip_list = agenda.trip.build_trip_list()
busy_events = agenda.busy.get_busy_events(now.date(), app.config, trip_list)
gaps = agenda.busy.find_gaps(busy_events)
return flask.render_template("gaps.html", today=now.date(), gaps=gaps)
@app.route("/weekends")
async def weekends() -> str:
"""List of available gaps."""
now = datetime.now()
trip_list = agenda.trip.build_trip_list()
busy_events = agenda.busy.get_busy_events(now.date(), app.config, trip_list)
weekends = agenda.busy.weekends(busy_events)
return flask.render_template("weekends.html", today=now.date(), items=weekends)
@app.route("/travel")
def travel_list() -> str:
"""Page showing a list of upcoming travel."""
data_dir = app.config["PERSONAL_DATA"]
flights = agenda.travel.parse_yaml("flights", data_dir)
trains = agenda.travel.parse_yaml("trains", data_dir)
flights = agenda.trip.load_flight_bookings(data_dir)
trains = [
item
for item in travel.parse_yaml("trains", data_dir)
if isinstance(item["depart"], datetime)
]
return flask.render_template("travel.html", flights=flights, trains=trains)
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
for train in trains:
for leg in train["legs"]:
agenda.travel.add_leg_route_distance(leg, route_distances)
if all("distance" in leg for leg in train["legs"]):
train["distance"] = sum(leg["distance"] for leg in train["legs"])
return flask.render_template(
"travel.html",
flights=flights,
trains=trains,
fx_rate=agenda.fx.get_rates(app.config),
)
def as_date(d: date | datetime) -> date:
"""Date of event."""
return d.date() if isinstance(d, datetime) else d
def build_conference_list() -> list[StrDict]:
"""Build conference list."""
data_dir = app.config["PERSONAL_DATA"]
filepath = os.path.join(data_dir, "conferences.yaml")
items: list[StrDict] = yaml.safe_load(open(filepath))
conference_trip_lookup = {}
for trip in agenda.trip.build_trip_list():
for trip_conf in trip.conferences:
key = (trip_conf["start"], trip_conf["name"])
conference_trip_lookup[key] = trip
for conf in items:
conf["start_date"] = agenda.utils.as_date(conf["start"])
conf["end_date"] = agenda.utils.as_date(conf["end"])
price = conf.get("price")
if price:
conf["price"] = decimal.Decimal(price)
key = (conf["start"], conf["name"])
if this_trip := conference_trip_lookup.get(key):
conf["linked_trip"] = this_trip
items.sort(key=operator.itemgetter("start_date"))
return items
@app.route("/conference")
def conference_list() -> str:
"""Page showing a list of conferences."""
data_dir = app.config["PERSONAL_DATA"]
filepath = os.path.join(data_dir, "conferences.yaml")
item_list = yaml.safe_load(open(filepath))["conferences"]
today = date.today()
for conf in item_list:
conf["start_date"] = as_date(conf["start"])
conf["end_date"] = as_date(conf["end"])
item_list.sort(key=operator.itemgetter("start_date"))
items = build_conference_list()
current = [
conf
for conf in item_list
for conf in items
if conf["start_date"] <= today and conf["end_date"] >= today
]
past = [conf for conf in item_list if conf["end_date"] < today]
future = [conf for conf in item_list if conf["start_date"] > today]
future = [conf for conf in items if conf["start_date"] > today]
return flask.render_template(
"conference_list.html", current=current, past=past, future=future, today=today
"conference_list.html",
current=current,
future=future,
today=today,
get_country=agenda.get_country,
fx_rate=agenda.fx.get_rates(app.config),
)
@app.route("/conference/past")
def past_conference_list() -> str:
"""Page showing a list of conferences."""
today = date.today()
return flask.render_template(
"conference_list.html",
past=[conf for conf in build_conference_list() if conf["end_date"] < today],
today=today,
get_country=agenda.get_country,
fx_rate=agenda.fx.get_rates(app.config),
)
@ -115,7 +324,7 @@ def conference_list() -> str:
def accommodation_list() -> str:
"""Page showing a list of past, present and future accommodation."""
data_dir = app.config["PERSONAL_DATA"]
items = agenda.travel.parse_yaml("accommodation", data_dir)
items = travel.parse_yaml("accommodation", data_dir)
stays_in_2024 = [item for item in items if item["from"].year == 2024]
total_nights_2024 = sum(
@ -128,13 +337,317 @@ def accommodation_list() -> str:
if stay["country"] != "gb"
)
trip_lookup = {}
for trip in agenda.trip.build_trip_list():
for trip_stay in trip.accommodation:
key = (trip_stay["from"], trip_stay["name"])
trip_lookup[key] = trip
for item in items:
key = (item["from"], item["name"])
if this_trip := trip_lookup.get(key):
item["linked_trip"] = this_trip
now = uk_tz.localize(datetime.now())
past = [conf for conf in items if conf["to"] < now]
current = [conf for conf in items if conf["from"] <= now and conf["to"] >= now]
future = [conf for conf in items if conf["from"] > now]
return flask.render_template(
"accommodation.html",
items=items,
past=past,
current=current,
future=future,
total_nights_2024=total_nights_2024,
nights_abroad_2024=nights_abroad_2024,
get_country=agenda.get_country,
fx_rate=agenda.fx.get_rates(app.config),
)
def get_trip_list(
route_distances: agenda.travel.RouteDistances | None = None,
) -> list[Trip]:
"""Get list of trips respecting current authentication status."""
return [
trip
for trip in agenda.trip.build_trip_list(route_distances=route_distances)
if flask.g.user.is_authenticated or not trip.private
]
@app.route("/trip/old")
def trip_old_list() -> str:
"""Page showing a list of trips."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
today = date.today()
current = [
item
for item in trip_list
if item.start <= today and (item.end or item.start) >= today
]
past = [item for item in trip_list if (item.end or item.start) < today]
future = [item for item in trip_list if item.start > today]
future_coordinates, future_routes = agenda.trip.get_coordinates_and_routes(future)
past_coordinates, past_routes = agenda.trip.get_coordinates_and_routes(past)
return flask.render_template(
"trip_list.html",
current=current,
past=past,
future=future,
future_coordinates=future_coordinates,
future_routes=future_routes,
past_coordinates=past_coordinates,
past_routes=past_routes,
today=today,
get_country=agenda.get_country,
format_list_with_ampersand=format_list_with_ampersand,
fx_rate=agenda.fx.get_rates(app.config),
)
@app.route("/trip")
def trip_list() -> werkzeug.Response:
"""Trip list to redirect to future trip list."""
return flask.redirect(flask.url_for("trip_future_list"))
def calc_total_distance(trips: list[Trip]) -> float:
"""Total distance for trips."""
total = 0.0
for item in trips:
dist = item.total_distance()
if dist:
total += dist
return total
def sum_distances_by_transport_type(trips: list[Trip]) -> list[tuple[str, float]]:
"""Sum distances by transport type."""
distances_by_transport_type: defaultdict[str, float] = defaultdict(float)
for trip in trips:
for transport_type, dist in trip.distances_by_transport_type():
distances_by_transport_type[transport_type] += dist
return list(distances_by_transport_type.items())
@app.route("/trip/past")
def trip_past_list() -> str:
"""Page showing a list of past trips."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
today = date.today()
past = [item for item in trip_list if (item.end or item.start) < today]
coordinates, routes = agenda.trip.get_coordinates_and_routes(past)
return flask.render_template(
"trip/list.html",
heading="Past trips",
trips=reversed(past),
coordinates=coordinates,
routes=routes,
today=today,
get_country=agenda.get_country,
format_list_with_ampersand=format_list_with_ampersand,
fx_rate=agenda.fx.get_rates(app.config),
total_distance=calc_total_distance(past),
distances_by_transport_type=sum_distances_by_transport_type(past),
)
@app.route("/trip/future")
def trip_future_list() -> str:
"""Page showing a list of future trips."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
today = date.today()
current = [
item
for item in trip_list
if item.start <= today and (item.end or item.start) >= today
]
future = [item for item in trip_list if item.start > today]
coordinates, routes = agenda.trip.get_coordinates_and_routes(current + future)
return flask.render_template(
"trip/list.html",
heading="Future trips",
trips=current + future,
coordinates=coordinates,
routes=routes,
today=today,
get_country=agenda.get_country,
format_list_with_ampersand=format_list_with_ampersand,
fx_rate=agenda.fx.get_rates(app.config),
total_distance=calc_total_distance(current + future),
distances_by_transport_type=sum_distances_by_transport_type(current + future),
)
@app.route("/trip/text")
def trip_list_text() -> str:
"""Page showing a list of trips."""
trip_list = get_trip_list()
today = date.today()
future = [item for item in trip_list if item.start > today]
return flask.render_template(
"trip_list_text.html",
future=future,
today=today,
get_country=agenda.get_country,
format_list_with_ampersand=format_list_with_ampersand,
)
def get_prev_current_and_next_trip(
start: str, trip_list: list[Trip]
) -> tuple[Trip | None, Trip | None, Trip | None]:
"""Get previous trip, this trip and next trip."""
trip_iter = iter(trip_list)
prev_trip = None
for trip in trip_iter:
if trip.start.isoformat() == start:
break
prev_trip = trip
next_trip = next(trip_iter, None)
return (prev_trip, trip, next_trip)
@app.route("/trip/<start>")
def trip_page(start: str) -> str:
"""Individual trip page."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
prev_trip, trip, next_trip = get_prev_current_and_next_trip(start, trip_list)
if not trip:
flask.abort(404)
today = date.today()
coordinates = agenda.trip.collect_trip_coordinates(trip)
routes = agenda.trip.get_trip_routes(trip)
agenda.trip.add_coordinates_for_unbooked_flights(routes, coordinates)
for route in routes:
if "geojson_filename" in route:
route["geojson"] = agenda.trip.read_geojson(
app.config["PERSONAL_DATA"], route.pop("geojson_filename")
)
if trip.end:
countries = {c.alpha_2 for c in trip.countries}
holidays = [
hol
for hol in agenda.holidays.get_all(
trip.start, trip.end, app.config["DATA_DIR"]
)
if hol.country.upper() in countries
]
holidays.sort(key=lambda item: (item.date, item.country))
else:
holidays = []
return flask.render_template(
"trip_page.html",
trip=trip,
prev_trip=prev_trip,
next_trip=next_trip,
today=today,
coordinates=coordinates,
routes=routes,
get_country=agenda.get_country,
format_list_with_ampersand=format_list_with_ampersand,
holidays=holidays,
human_readable_delta=agenda.utils.human_readable_delta,
)
@app.route("/holidays")
def holiday_list() -> str:
"""List of holidays."""
today = date.today()
data_dir = app.config["DATA_DIR"]
next_year = today + timedelta(days=1 * 365)
items = agenda.holidays.get_all(today - timedelta(days=2), next_year, data_dir)
items.sort(key=lambda item: (item.date, item.country))
return flask.render_template(
"holiday_list.html", items=items, get_country=agenda.get_country, today=today
)
@app.route("/birthdays")
def birthday_list() -> str:
"""List of birthdays."""
today = date.today()
if not flask.g.user.is_authenticated:
flask.abort(401)
data_dir = app.config["PERSONAL_DATA"]
entities_file = os.path.join(data_dir, "entities.yaml")
items = agenda.birthday.get_birthdays(today - timedelta(days=2), entities_file)
items.sort(key=lambda item: item.date)
return flask.render_template("birthday_list.html", items=items, today=today)
@app.route("/trip/stats")
def trip_stats() -> str:
"""Travel stats: distance and price by year and travel type."""
route_distances = agenda.travel.load_route_distances(app.config["DATA_DIR"])
trip_list = get_trip_list(route_distances)
today = date.today()
past = [item for item in trip_list if (item.end or item.start) < today]
conferences = sum(len(item.conferences) for item in past)
yearly_stats = agenda.trip.calculate_yearly_stats(past)
return flask.render_template(
"trip/stats.html",
count=len(past),
total_distance=calc_total_distance(past),
distances_by_transport_type=sum_distances_by_transport_type(past),
yearly_stats=yearly_stats,
conferences=conferences,
)
@app.route("/callback")
def auth_callback() -> tuple[str, int] | werkzeug.Response:
"""Process the authentication callback."""
return UniAuth.auth.auth_callback()
@app.route("/login")
def login() -> werkzeug.Response:
"""Login."""
next_url = flask.request.args["next"]
return UniAuth.auth.redirect_to_login(next_url)
@app.route("/logout")
def logout() -> werkzeug.Response:
"""Logout."""
return UniAuth.auth.redirect_to_logout(flask.request.args["next"])
if __name__ == "__main__":
app.run(host="0.0.0.0")

18
webpack.config.js Normal file
View file

@ -0,0 +1,18 @@
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
mode: 'development',
entry: './frontend/index.js', // Ensure this entry point exists and is valid.
plugins: [
new CopyPlugin({
patterns: [
// Copy Bootstrap's CSS and JS from node_modules to your desired location
{ from: 'node_modules/bootstrap/dist', to: path.resolve(__dirname, 'static/bootstrap5') },
{ from: 'node_modules/leaflet/dist', to: path.resolve(__dirname, 'static/leaflet') },
{ from: 'node_modules/leaflet.geodesic/dist', to: path.resolve(__dirname, 'static/leaflet-geodesic'), },
{ from: 'node_modules/es-module-shims/dist', to: path.resolve(__dirname, 'static/es-module-shims') }
],
}),
]
};