diff --git a/poetry.lock b/poetry.lock
index 19b7fcd9..b9c4d2e0 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,4 +1,4 @@
-# This file is automatically @generated by Poetry 1.5.1 and should not be changed by hand.
+# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
 
 [[package]]
 name = "aio-mc-rcon"
@@ -821,6 +821,98 @@ files = [
     {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
 ]
 
+[[package]]
+name = "pillow"
+version = "11.0.0"
+description = "Python Imaging Library (Fork)"
+optional = false
+python-versions = ">=3.9"
+files = [
+    {file = "pillow-11.0.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6619654954dc4936fcff82db8eb6401d3159ec6be81e33c6000dfd76ae189947"},
+    {file = "pillow-11.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b3c5ac4bed7519088103d9450a1107f76308ecf91d6dabc8a33a2fcfb18d0fba"},
+    {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a65149d8ada1055029fcb665452b2814fe7d7082fcb0c5bed6db851cb69b2086"},
+    {file = "pillow-11.0.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88a58d8ac0cc0e7f3a014509f0455248a76629ca9b604eca7dc5927cc593c5e9"},
+    {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c26845094b1af3c91852745ae78e3ea47abf3dbcd1cf962f16b9a5fbe3ee8488"},
+    {file = "pillow-11.0.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1a61b54f87ab5786b8479f81c4b11f4d61702830354520837f8cc791ebba0f5f"},
+    {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:674629ff60030d144b7bca2b8330225a9b11c482ed408813924619c6f302fdbb"},
+    {file = "pillow-11.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:598b4e238f13276e0008299bd2482003f48158e2b11826862b1eb2ad7c768b97"},
+    {file = "pillow-11.0.0-cp310-cp310-win32.whl", hash = "sha256:9a0f748eaa434a41fccf8e1ee7a3eed68af1b690e75328fd7a60af123c193b50"},
+    {file = "pillow-11.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:a5629742881bcbc1f42e840af185fd4d83a5edeb96475a575f4da50d6ede337c"},
+    {file = "pillow-11.0.0-cp310-cp310-win_arm64.whl", hash = "sha256:ee217c198f2e41f184f3869f3e485557296d505b5195c513b2bfe0062dc537f1"},
+    {file = "pillow-11.0.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1c1d72714f429a521d8d2d018badc42414c3077eb187a59579f28e4270b4b0fc"},
+    {file = "pillow-11.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:499c3a1b0d6fc8213519e193796eb1a86a1be4b1877d678b30f83fd979811d1a"},
+    {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c8b2351c85d855293a299038e1f89db92a2f35e8d2f783489c6f0b2b5f3fe8a3"},
+    {file = "pillow-11.0.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f4dba50cfa56f910241eb7f883c20f1e7b1d8f7d91c750cd0b318bad443f4d5"},
+    {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:5ddbfd761ee00c12ee1be86c9c0683ecf5bb14c9772ddbd782085779a63dd55b"},
+    {file = "pillow-11.0.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:45c566eb10b8967d71bf1ab8e4a525e5a93519e29ea071459ce517f6b903d7fa"},
+    {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b4fd7bd29610a83a8c9b564d457cf5bd92b4e11e79a4ee4716a63c959699b306"},
+    {file = "pillow-11.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:cb929ca942d0ec4fac404cbf520ee6cac37bf35be479b970c4ffadf2b6a1cad9"},
+    {file = "pillow-11.0.0-cp311-cp311-win32.whl", hash = "sha256:006bcdd307cc47ba43e924099a038cbf9591062e6c50e570819743f5607404f5"},
+    {file = "pillow-11.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:52a2d8323a465f84faaba5236567d212c3668f2ab53e1c74c15583cf507a0291"},
+    {file = "pillow-11.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:16095692a253047fe3ec028e951fa4221a1f3ed3d80c397e83541a3037ff67c9"},
+    {file = "pillow-11.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d2c0a187a92a1cb5ef2c8ed5412dd8d4334272617f532d4ad4de31e0495bd923"},
+    {file = "pillow-11.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:084a07ef0821cfe4858fe86652fffac8e187b6ae677e9906e192aafcc1b69903"},
+    {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8069c5179902dcdce0be9bfc8235347fdbac249d23bd90514b7a47a72d9fecf4"},
+    {file = "pillow-11.0.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f02541ef64077f22bf4924f225c0fd1248c168f86e4b7abdedd87d6ebaceab0f"},
+    {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:fcb4621042ac4b7865c179bb972ed0da0218a076dc1820ffc48b1d74c1e37fe9"},
+    {file = "pillow-11.0.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:00177a63030d612148e659b55ba99527803288cea7c75fb05766ab7981a8c1b7"},
+    {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8853a3bf12afddfdf15f57c4b02d7ded92c7a75a5d7331d19f4f9572a89c17e6"},
+    {file = "pillow-11.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3107c66e43bda25359d5ef446f59c497de2b5ed4c7fdba0894f8d6cf3822dafc"},
+    {file = "pillow-11.0.0-cp312-cp312-win32.whl", hash = "sha256:86510e3f5eca0ab87429dd77fafc04693195eec7fd6a137c389c3eeb4cfb77c6"},
+    {file = "pillow-11.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:8ec4a89295cd6cd4d1058a5e6aec6bf51e0eaaf9714774e1bfac7cfc9051db47"},
+    {file = "pillow-11.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:27a7860107500d813fcd203b4ea19b04babe79448268403172782754870dac25"},
+    {file = "pillow-11.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:bcd1fb5bb7b07f64c15618c89efcc2cfa3e95f0e3bcdbaf4642509de1942a699"},
+    {file = "pillow-11.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0e038b0745997c7dcaae350d35859c9715c71e92ffb7e0f4a8e8a16732150f38"},
+    {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ae08bd8ffc41aebf578c2af2f9d8749d91f448b3bfd41d7d9ff573d74f2a6b2"},
+    {file = "pillow-11.0.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d69bfd8ec3219ae71bcde1f942b728903cad25fafe3100ba2258b973bd2bc1b2"},
+    {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:61b887f9ddba63ddf62fd02a3ba7add935d053b6dd7d58998c630e6dbade8527"},
+    {file = "pillow-11.0.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c6a660307ca9d4867caa8d9ca2c2658ab685de83792d1876274991adec7b93fa"},
+    {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:73e3a0200cdda995c7e43dd47436c1548f87a30bb27fb871f352a22ab8dcf45f"},
+    {file = "pillow-11.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fba162b8872d30fea8c52b258a542c5dfd7b235fb5cb352240c8d63b414013eb"},
+    {file = "pillow-11.0.0-cp313-cp313-win32.whl", hash = "sha256:f1b82c27e89fffc6da125d5eb0ca6e68017faf5efc078128cfaa42cf5cb38798"},
+    {file = "pillow-11.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ba470552b48e5835f1d23ecb936bb7f71d206f9dfeee64245f30c3270b994de"},
+    {file = "pillow-11.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:846e193e103b41e984ac921b335df59195356ce3f71dcfd155aa79c603873b84"},
+    {file = "pillow-11.0.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4ad70c4214f67d7466bea6a08061eba35c01b1b89eaa098040a35272a8efb22b"},
+    {file = "pillow-11.0.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:6ec0d5af64f2e3d64a165f490d96368bb5dea8b8f9ad04487f9ab60dc4bb6003"},
+    {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c809a70e43c7977c4a42aefd62f0131823ebf7dd73556fa5d5950f5b354087e2"},
+    {file = "pillow-11.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:4b60c9520f7207aaf2e1d94de026682fc227806c6e1f55bba7606d1c94dd623a"},
+    {file = "pillow-11.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1e2688958a840c822279fda0086fec1fdab2f95bf2b717b66871c4ad9859d7e8"},
+    {file = "pillow-11.0.0-cp313-cp313t-win32.whl", hash = "sha256:607bbe123c74e272e381a8d1957083a9463401f7bd01287f50521ecb05a313f8"},
+    {file = "pillow-11.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5c39ed17edea3bc69c743a8dd3e9853b7509625c2462532e62baa0732163a904"},
+    {file = "pillow-11.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:75acbbeb05b86bc53cbe7b7e6fe00fbcf82ad7c684b3ad82e3d711da9ba287d3"},
+    {file = "pillow-11.0.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:2e46773dc9f35a1dd28bd6981332fd7f27bec001a918a72a79b4133cf5291dba"},
+    {file = "pillow-11.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:2679d2258b7f1192b378e2893a8a0a0ca472234d4c2c0e6bdd3380e8dfa21b6a"},
+    {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eda2616eb2313cbb3eebbe51f19362eb434b18e3bb599466a1ffa76a033fb916"},
+    {file = "pillow-11.0.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:20ec184af98a121fb2da42642dea8a29ec80fc3efbaefb86d8fdd2606619045d"},
+    {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:8594f42df584e5b4bb9281799698403f7af489fba84c34d53d1c4bfb71b7c4e7"},
+    {file = "pillow-11.0.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:c12b5ae868897c7338519c03049a806af85b9b8c237b7d675b8c5e089e4a618e"},
+    {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:70fbbdacd1d271b77b7721fe3cdd2d537bbbd75d29e6300c672ec6bb38d9672f"},
+    {file = "pillow-11.0.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:5178952973e588b3f1360868847334e9e3bf49d19e169bbbdfaf8398002419ae"},
+    {file = "pillow-11.0.0-cp39-cp39-win32.whl", hash = "sha256:8c676b587da5673d3c75bd67dd2a8cdfeb282ca38a30f37950511766b26858c4"},
+    {file = "pillow-11.0.0-cp39-cp39-win_amd64.whl", hash = "sha256:94f3e1780abb45062287b4614a5bc0874519c86a777d4a7ad34978e86428b8dd"},
+    {file = "pillow-11.0.0-cp39-cp39-win_arm64.whl", hash = "sha256:290f2cc809f9da7d6d622550bbf4c1e57518212da51b6a30fe8e0a270a5b78bd"},
+    {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1187739620f2b365de756ce086fdb3604573337cc28a0d3ac4a01ab6b2d2a6d2"},
+    {file = "pillow-11.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fbbcb7b57dc9c794843e3d1258c0fbf0f48656d46ffe9e09b63bbd6e8cd5d0a2"},
+    {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d203af30149ae339ad1b4f710d9844ed8796e97fda23ffbc4cc472968a47d0b"},
+    {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a0d3b115009ebb8ac3d2ebec5c2982cc693da935f4ab7bb5c8ebe2f47d36f2"},
+    {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:73853108f56df97baf2bb8b522f3578221e56f646ba345a372c78326710d3830"},
+    {file = "pillow-11.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e58876c91f97b0952eb766123bfef372792ab3f4e3e1f1a2267834c2ab131734"},
+    {file = "pillow-11.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:224aaa38177597bb179f3ec87eeefcce8e4f85e608025e9cfac60de237ba6316"},
+    {file = "pillow-11.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:5bd2d3bdb846d757055910f0a59792d33b555800813c3b39ada1829c372ccb06"},
+    {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:375b8dd15a1f5d2feafff536d47e22f69625c1aa92f12b339ec0b2ca40263273"},
+    {file = "pillow-11.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:daffdf51ee5db69a82dd127eabecce20729e21f7a3680cf7cbb23f0829189790"},
+    {file = "pillow-11.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7326a1787e3c7b0429659e0a944725e1b03eeaa10edd945a86dead1913383944"},
+    {file = "pillow-11.0.0.tar.gz", hash = "sha256:72bacbaf24ac003fea9bff9837d1eedb6088758d41e100c1552930151f677739"},
+]
+
+[package.extras]
+docs = ["furo", "olefile", "sphinx (>=8.1)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
+fpx = ["olefile"]
+mic = ["olefile"]
+tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
+typing = ["typing-extensions"]
+xmp = ["defusedxml"]
+
 [[package]]
 name = "platformdirs"
 version = "4.2.1"
@@ -1017,13 +1109,13 @@ cli = ["click (>=5.0)"]
 
 [[package]]
 name = "pytz"
-version = "2023.4"
+version = "2024.2"
 description = "World timezone definitions, modern and historical"
 optional = false
 python-versions = "*"
 files = [
-    {file = "pytz-2023.4-py2.py3-none-any.whl", hash = "sha256:f90ef520d95e7c46951105338d918664ebfd6f1d995bd7d153127ce90efafa6a"},
-    {file = "pytz-2023.4.tar.gz", hash = "sha256:31d4583c4ed539cd037956140d695e42c033a19e984bfce9964a3f7d59bc2b40"},
+    {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"},
+    {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"},
 ]
 
 [[package]]
@@ -1204,7 +1296,7 @@ files = [
 ]
 
 [package.dependencies]
-greenlet = {version = "!=0.4.17", markers = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\""}
+greenlet = {version = "!=0.4.17", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""}
 psycopg2-binary = {version = "*", optional = true, markers = "extra == \"postgresql-psycopg2binary\""}
 typing-extensions = ">=4.6.0"
 
@@ -1318,13 +1410,13 @@ files = [
 
 [[package]]
 name = "tzdata"
-version = "2024.1"
+version = "2024.2"
 description = "Provider of IANA time zone data"
 optional = false
 python-versions = ">=2"
 files = [
-    {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"},
-    {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"},
+    {file = "tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd"},
+    {file = "tzdata-2024.2.tar.gz", hash = "sha256:7d85cc416e9382e69095b7bdf4afd9e3880418a2413feec7069d533d6b4e31cc"},
 ]
 
 [[package]]
@@ -1467,4 +1559,4 @@ multidict = ">=4.0"
 [metadata]
 lock-version = "2.0"
 python-versions = "^3.10"
-content-hash = "cf0759e0860a4edb222e420d688b3857f1decc8fbde7425597428697500e460a"
+content-hash = "16927096e101f554ba000dca244fff62dbb5d70a4f99e21dd5cee3c6323823bd"
diff --git a/pyproject.toml b/pyproject.toml
index 16053455..b2c691e7 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,7 +10,6 @@ python = "^3.10"
 "discord.py" = "^2.3.2"
 icalendar = "^5.0"
 "python-dateutil" = "^2.9"
-pytz = "^2023.3"
 requests = "^2.31"
 beautifulsoup4 = "^4.12"
 APScheduler = "^3.10"
@@ -20,6 +19,8 @@ aiohttp = "^3.9"
 aio-mc-rcon = "^3.2.0"
 PyYAML = "^6.0"
 mcstatus = "^11.1.0"
+pillow = "^11.0.0"
+tzdata = "*"
 
 [tool.poetry.scripts]
 botdev = "dev.cli:main"
diff --git a/uqcsbot/advent.py b/uqcsbot/advent.py
index 9fe27474..a7119346 100644
--- a/uqcsbot/advent.py
+++ b/uqcsbot/advent.py
@@ -2,7 +2,7 @@
 import os
 from datetime import datetime
 from random import choices
-from typing import Callable, Dict, Iterable, List, Optional, Literal
+from typing import Any, Callable, Dict, Iterable, List, Optional, Literal
 import requests
 from requests.exceptions import RequestException
 
@@ -14,18 +14,23 @@
 from uqcsbot.models import AOCRegistrations, AOCWinners
 from uqcsbot.utils.err_log_utils import FatalErrorWithLog
 from uqcsbot.utils.advent_utils import (
+    Leaderboard,
     Member,
     Day,
     Json,
     InvalidHTTPSCode,
     ADVENT_DAYS,
     CACHE_TIME,
+    HL_COLOUR,
     parse_leaderboard_column_string,
-    print_leaderboard,
+    build_leaderboard,
+    render_leaderboard_to_image,
+    render_leaderboard_to_text,
 )
 
 # Leaderboard API URL with placeholders for year and code.
-LEADERBOARD_URL = "https://adventofcode.com/{year}/leaderboard/private/view/{code}.json"
+LEADERBOARD_VIEW_URL = "https://adventofcode.com/{year}/leaderboard/private/view/{code}"
+LEADERBOARD_URL = LEADERBOARD_VIEW_URL + ".json"
 
 # UQCS leaderboard ID.
 UQCS_LEADERBOARD = 989288
@@ -134,6 +139,129 @@
 }
 
 
+class LeaderboardView(discord.ui.View):
+    TRUNCATED_COUNT = 20
+    TIMEOUT = 180  # seconds
+
+    def __init__(
+        self,
+        bot: UQCSBot,
+        inter: discord.Interaction,
+        code: int,
+        year: int,
+        day: Optional[Day],
+        members: list[Member],
+        leaderboard_style: str,
+        sortby: Optional[SortingMethod],
+    ):
+        super().__init__(timeout=self.TIMEOUT)
+
+        # constant within one embed
+        self.bot = bot
+        self.inter = inter
+        self.code = code
+        self.year = year
+        self.day = day
+        self.all_members = members
+        self.leaderboard_style = leaderboard_style
+        self.sortby = sortby
+        self.timestamp = datetime.now()
+        self.basename = f"advent_{self.code}_{self.year}_{self.day}"
+
+        # can be changed by interaction
+        self._visible_members = members[: self.TRUNCATED_COUNT]
+
+    @property
+    def is_truncated(self):
+        return len(self._visible_members) < len(self.all_members)
+
+    def _build_leaderboard(self, members: List[Member]) -> Leaderboard:
+        return build_leaderboard(
+            parse_leaderboard_column_string(self.leaderboard_style, self.bot),
+            members,
+            self.day,
+        )
+
+    def make_message_arguments(self) -> Dict[str, Any]:
+        view_url = LEADERBOARD_VIEW_URL.format(year=self.year, code=self.code)
+
+        title = (
+            "Advent of Code UQCS Leaderboard"
+            if self.code == UQCS_LEADERBOARD
+            else f"Advent of Code Leaderboard `{self.code}`"
+        )
+        title = f":star: {title} :trophy:"
+        if self.day:
+            title += f" \u2014 Day {self.day}"
+
+        notes: List[str] = []
+        if self.day:
+            notes.append(f"sorted by {self.sortby}")
+        if self.is_truncated:
+            notes.append(
+                f"top {len(self._visible_members)} shown out of {len(self.all_members)}"
+            )
+        body = f"({', '.join(notes)})" if notes else ""
+
+        embed = discord.Embed(
+            title=title,
+            url=view_url,
+            description=body,
+            colour=discord.Colour.from_str(HL_COLOUR),
+            timestamp=self.timestamp,
+        )
+
+        leaderboard = self._build_leaderboard(self._visible_members)
+        scoreboard_image = render_leaderboard_to_image(leaderboard)
+        file = discord.File(io.BytesIO(scoreboard_image), self.basename + ".png")
+        embed.set_image(url=f"attachment://{file.filename}")
+
+        self.show_all_interaction.disabled = (
+            len(self.all_members) <= self.TRUNCATED_COUNT
+        )
+        self.show_all_interaction.label = (
+            "Show all" if self.is_truncated else "Show less"
+        )
+
+        return {
+            "attachments": [file],
+            "embed": embed,
+            "view": self,
+        }
+
+    @discord.ui.button(label="Show all", style=discord.ButtonStyle.gray)
+    async def show_all_interaction(
+        self, inter: discord.Interaction, btn: discord.ui.Button["LeaderboardView"]
+    ):
+        self._visible_members = (
+            self.all_members
+            if self.is_truncated
+            else self.all_members[: self.TRUNCATED_COUNT]
+        )
+        await inter.response.edit_message(**self.make_message_arguments())
+
+    @discord.ui.button(label="Export as text", style=discord.ButtonStyle.gray)
+    async def get_text_interaction(
+        self, inter: discord.Interaction, btn: discord.ui.Button["LeaderboardView"]
+    ):
+        """
+        Sends the text leaderboard as a file attachment within a new reply.
+        """
+        leaderboard = self._build_leaderboard(self.all_members)
+        text = render_leaderboard_to_text(leaderboard)
+        file = discord.File(io.BytesIO(text.encode("utf-8")), self.basename + ".txt")
+        await inter.response.send_message(file=file)
+
+        btn.disabled = True
+        await self.inter.edit_original_response(view=self)
+
+    async def on_timeout(self) -> None:
+        """
+        Detach interactable view on timeout.
+        """
+        await self.inter.edit_original_response(view=None)
+
+
 class Advent(commands.Cog):
     """
     All of the commands related to Advent of Code (AOC).
@@ -403,7 +531,7 @@ async def help_command(
             case "register":
                 await interaction.response.send_message(
                     """
-`/advent register` links an Advent of Code account and a discord user so that you are eligble for prizes. Each Advent of Code account and discord account can only be linked to one other account each year. Note that registrations last for only the current year. If you are having any issues with this, message committee to help.
+`/advent register` links an Advent of Code account and a discord user so that you are eligble for prizes. Each Advent of Code account and discord account can only be linked to one other account. Registrations persist across years. If you are having any issues with this, message committee to help.
                     """
                 )
             case "register-force":
@@ -502,13 +630,7 @@ async def leaderboard_command(
             )
             return
 
-        if code == UQCS_LEADERBOARD:
-            message = ":star: *Advent of Code UQCS Leaderboard* :trophy:"
-        else:
-            message = f":star: *Advent of Code Leaderboard {code}* :trophy:"
-
         if day:
-            message += f"\n:calendar: *Day {day}* (Sorted By {sortby})"
             members = [member for member in members if member.attempted_day(day)]
             members.sort(key=lambda m: sorting_functions_for_day[sortby](m, day))
         else:
@@ -525,25 +647,10 @@ async def leaderboard_command(
             )
             return
 
-        scoreboard_file = io.BytesIO(
-            bytes(
-                print_leaderboard(
-                    parse_leaderboard_column_string(leaderboard_style, self.bot),
-                    members,
-                    day,
-                ),
-                "utf-8",
-            )
-        )
-        await interaction.edit_original_response(
-            content=message,
-            attachments=[
-                discord.File(
-                    scoreboard_file,
-                    filename=f"advent_{code}_{year}_{day}.txt",
-                )
-            ],
+        view = LeaderboardView(
+            self.bot, interaction, code, year, day, members, leaderboard_style, sortby
         )
+        await interaction.edit_original_response(**view.make_message_arguments())
 
     @advent_command_group.command(name="register")
     @app_commands.describe(
@@ -562,7 +669,10 @@ async def register_command(self, interaction: discord.Interaction, aoc_name: str
         members = self._get_members(year)
         if aoc_name not in [member.name for member in members]:
             await interaction.edit_original_response(
-                content=f"Could not find the Advent of Code name `{aoc_name}` within the UQCS leaderboard."
+                content=(
+                    f"Could not find the Advent of Code name `{aoc_name}` within the UQCS leaderboard. Make sure your name appears at: "
+                    + LEADERBOARD_VIEW_URL.format(code=UQCS_LEADERBOARD, year=year)
+                )
             )
             return
         member = [member for member in members if member.name == aoc_name]
@@ -580,13 +690,17 @@ async def register_command(self, interaction: discord.Interaction, aoc_name: str
         )
         if query is not None:
             discord_user = self.bot.uqcs_server.get_member(query.discord_userid)
+            is_self = False
             if discord_user:
                 discord_ping = discord_user.mention
+                is_self = discord_user.id == interaction.user.id
             else:
                 discord_ping = f"someone who doesn't seem to be in the server (discord id = {query.discord_userid})"
-            await interaction.edit_original_response(
-                content=f"Advent of Code name `{aoc_name}` is already registered to {discord_ping}. Please contact committee if this is your Advent of Code name."
-            )
+            if not is_self:
+                message = f"Advent of Code name `{aoc_name}` is already registered to {discord_ping}. Please contact committee if this is your Advent of Code name."
+            else:
+                message = f"Advent of Code name `{aoc_name}` is already registered to you ({discord_ping})! Please contact committee if this is incorrect."
+            await interaction.edit_original_response(content=message)
             return
 
         discord_id = interaction.user.id
diff --git a/uqcsbot/static/NotoSansMono-Regular.ttf b/uqcsbot/static/NotoSansMono-Regular.ttf
new file mode 100644
index 00000000..c2bbb5a3
Binary files /dev/null and b/uqcsbot/static/NotoSansMono-Regular.ttf differ
diff --git a/uqcsbot/utils/advent_utils.py b/uqcsbot/utils/advent_utils.py
index 7d3ba168..a7d294b3 100644
--- a/uqcsbot/utils/advent_utils.py
+++ b/uqcsbot/utils/advent_utils.py
@@ -1,6 +1,24 @@
-from typing import Any, List, Literal, Dict, Optional, Callable
+from typing import (
+    Any,
+    DefaultDict,
+    List,
+    Literal,
+    Dict,
+    Optional,
+    Callable,
+    NamedTuple,
+    Tuple,
+    cast,
+)
+from collections import defaultdict
 from datetime import datetime, timedelta
-from pytz import timezone
+from zoneinfo import ZoneInfo
+from io import BytesIO
+
+import PIL.Image
+import PIL.ImageDraw
+import PIL.ImageFont
+import PIL.features
 
 from uqcsbot.bot import UQCSBot
 from uqcsbot.models import AOCRegistrations
@@ -15,13 +33,24 @@
 Times = Dict[Star, Seconds]
 Delta = Optional[Seconds]
 Json = Dict[str, Any]
+Colour = str
+ColourFragment = NamedTuple("ColourFragment", [("text", str), ("colour", Colour)])
+Leaderboard = list[str | ColourFragment]
 
 # Puzzles are unlocked at midnight EST.
-EST_TIMEZONE = timezone("US/Eastern")
+EST_TIMEZONE = ZoneInfo("America/New_York")
 
 # The time to cache results to limit requests to adventofcode.com. Note that 15 minutes is the recomended minimum time.
 CACHE_TIME = timedelta(minutes=15)
 
+# Colours borrowed from adventofcode.com website
+# https://adventofcode.com/static/style.css
+BG_COLOUR = "#0f0f23"
+FG_COLOUR = "#cccccc"
+HL_COLOUR = "#009900"
+GOLD_COLOUR = "#ffff66"
+SILVER_COLOUR = "#9999cc"
+
 
 class InvalidHTTPSCode(Exception):
     def __init__(self, message: str, request_code: int):
@@ -65,14 +94,17 @@ def from_member_data(cls, data: Json, year: int) -> "Member":
             day = int(d)
             times = member.times[day]
 
-            # timestamp of puzzle unlock, rounded to whole seconds
-            DAY_START = int(datetime(year, 12, day, tzinfo=EST_TIMEZONE).timestamp())
+            # timestamp of puzzle unlock (12AM EST)
+            DAY_START = datetime(year, 12, day, tzinfo=EST_TIMEZONE)
 
             for s, star_data in day_data.items():
                 star = int(s)
                 # assert is for type checking
                 assert star == 1 or star == 2
-                times[star] = int(star_data["get_star_ts"]) - DAY_START
+                ts = datetime.fromtimestamp(
+                    float(star_data["get_star_ts"]), tz=EST_TIMEZONE
+                )
+                times[star] = int((ts - DAY_START).total_seconds())
                 assert times[star] >= 0
 
         return member
@@ -104,7 +136,7 @@ def get_total_star2_time(self, default: int = 0) -> int:
         Returns the total time working on just star 2 for all challenges in a year.
         The argument default determines the returned value if the total is 0.
         """
-        total = sum(self.times[day].get(2, 0) for day in ADVENT_DAYS)
+        total = sum(self.get_time_delta(day) or 0 for day in ADVENT_DAYS)
         return total if total != 0 else default
 
     def get_total_time(self, default: int = 0) -> int:
@@ -136,7 +168,9 @@ def _star_char(num_stars: int):
     Given a number of stars (0, 1, or 2), returns its leaderboard
     representation.
     """
-    return " .*"[num_stars]
+    return ColourFragment(
+        " .*"[num_stars], [FG_COLOUR, SILVER_COLOUR, GOLD_COLOUR][num_stars]
+    )
 
 
 def _format_seconds(seconds: Optional[int]):
@@ -164,8 +198,8 @@ def _format_seconds_long(seconds: Optional[int]):
     return f"{hours}:{minutes:02}:{seconds:02}"
 
 
-def _get_member_star_progress_bar(member: Member):
-    return "".join(_star_char(len(member.times[day])) for day in ADVENT_DAYS)
+def _get_member_star_progress_bar(member: Member) -> Leaderboard:
+    return [_star_char(len(member.times[day])) for day in ADVENT_DAYS]
 
 
 class LeaderboardColumn:
@@ -176,7 +210,7 @@ class LeaderboardColumn:
     def __init__(
         self,
         title: tuple[str, str],
-        calculation: Callable[[Member, int, Optional[Day]], str],
+        calculation: Callable[[Member, int, Optional[Day]], str | Leaderboard],
     ):
         self.title = title
         self.calculation = calculation
@@ -297,13 +331,16 @@ def name_column(bot: UQCSBot):
         A column listing each name.
         """
 
-        def format_name(member: Member, _: int, __: Optional[int]) -> str:
+        def format_name(member: Member, _: int, __: Optional[int]) -> Leaderboard:
             if not (discord_userid := member.get_discord_userid(bot)):
-                return member.name
+                return [member.name]
             if not (discord_user := bot.uqcs_server.get_member(discord_userid)):
-                return member.name
+                return [member.name]
             # Don't actually ping as leaderboard is called many times
-            return f"{member.name} (@{discord_user.display_name})"
+            return [
+                ColourFragment(member.name, HL_COLOUR),
+                f" (@{discord_user.display_name})",
+            ]
 
         return LeaderboardColumn(title=("", ""), calculation=format_name)
 
@@ -366,21 +403,85 @@ def parse_leaderboard_column_string(s: str, bot: UQCSBot) -> List[LeaderboardCol
     return columns
 
 
-def print_leaderboard(
+def render_leaderboard_to_text(leaderboard: Leaderboard) -> str:
+    return "".join(x if isinstance(x, str) else x.text for x in leaderboard)
+
+
+def _isolate_leaderboard_layers(
+    leaderboard: Leaderboard,
+) -> Tuple[str, Dict[Colour, str]]:
+    """
+    Given a leaderboard made up of coloured fragments, split the
+    text into a number of layers. Each layer contains all the text
+    which is coloured by one particular colour.
+
+    Returns:
+    - a string of the leaderboard, but with whitespace in the place of every character,
+      for calculating bounding box size.
+    - a dictionary mapping colours to the layer of that colour.
+    """
+    layers: DefaultDict[str | None, str] = defaultdict(lambda: layers[None])
+    layers[None] = ""
+
+    for frag in leaderboard:
+        colour, text = (
+            (FG_COLOUR, frag) if isinstance(frag, str) else (frag.colour, frag.text)
+        )
+        layers[colour] += text
+        for k in layers:
+            if k == colour:
+                continue
+            layers[k] += "".join(c if c.isspace() else " " for c in text)
+
+    spaces_str = layers.pop(None)
+    return spaces_str, cast(Dict[str, Any], layers)
+
+
+def render_leaderboard_to_image(leaderboard: Leaderboard) -> bytes:
+    spaces, layers = _isolate_leaderboard_layers(leaderboard)
+
+    # NOTE: font choice should support as wide a range of glyphs as possible,
+    # since discord display names are arbitrary and pillow does not support
+    # fallback fonts.
+    # font must also be monospace, in order for the colour layers to be aligned.
+    font = PIL.ImageFont.truetype("./uqcsbot/static/NotoSansMono-Regular.ttf", 20)
+
+    img = PIL.Image.new("RGB", (1, 1))
+    draw = PIL.ImageDraw.Draw(img)
+
+    PAD = 20
+    # first, try to draw text to obtain required bounding box size
+    _, _, right, bottom = draw.textbbox((PAD, PAD), spaces, font=font)  # type: ignore
+
+    img = PIL.Image.new("RGB", (int(right) + PAD, int(bottom) + PAD), BG_COLOUR)
+    draw = PIL.ImageDraw.Draw(img)
+
+    # draw each layer. layers should be disjoint
+    for colour, text in layers.items():
+        draw.text((PAD, PAD), text, font=font, fill=colour)  # type: ignore
+
+    buf = BytesIO()
+    img.save(buf, format="PNG", optimize=True)
+    return buf.getvalue()  # XXX: why do we need to getvalue()?
+
+
+def build_leaderboard(
     columns: List[LeaderboardColumn], members: List[Member], day: Optional[Day]
 ):
     """
-    Returns a string of the leaderboard of the given format.
+    Returns a leaderboard made up of fragments, with the given column configuration
+    and member rows.
     """
-    leaderboard = "".join(column.title[0] for column in columns)
-    leaderboard += "\n"
-    leaderboard += "".join(column.title[1] for column in columns)
+    header = "".join(column.title[0] for column in columns)
+    header += "\n"
+    header += "".join(column.title[1] for column in columns)
+
+    leaderboard: Leaderboard = [ColourFragment(header, HL_COLOUR)]
 
     # Note that leaderboards start at 1, not 0
     for id, member in enumerate(members, start=1):
-        leaderboard += "\n"
-        leaderboard += "".join(
-            column.calculation(member, id, day) for column in columns
-        )
+        leaderboard.append("\n")
+        for column in columns:
+            leaderboard.extend(column.calculation(member, id, day))
 
     return leaderboard