diff --git a/.gitignore b/.gitignore
index a60ef1080bf2d7b04a20f45863f53cc8136c6be7..0e6a272859e872a4d5f17194df076399e6a8b0ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,8 @@
 *.mbp
+*.pyc
+/.idea
+config.yaml
+bot.db
+bot.db-shm
+bot.db-wal
+config.yaml
diff --git a/README.md b/README.md
index 62eda7a9a8f9a34258bdf9ddf7e946d6f1ee7236..a77bb89c859f9517b40e5ddd7f419af631bc0e2a 100644
--- a/README.md
+++ b/README.md
@@ -1,30 +1,97 @@
-# reminder
+# ⏰ reminder-agenda bot
 A [maubot](https://github.com/maubot/maubot) to remind you about things.
 
-## Hosted instances
-* maunium.net: [@reminder:maunium.net](https://matrix.to/#/@reminder:maunium.net).
-* t2bot.io: https://t2bot.io/reminderbot/
+Basically [matrix-reminder-bot](https://github.com/anoadragon453/matrix-reminder-bot/tree/master) and [maubot/reminder](https://github.com/maubot/reminder) smushed together. This project includes code taken from both repositories, credit goes to them!
+
+![example of interacting with the bot](screenshot.png)
+
+## Features
+
+* Set once-off reminders and recurring reminders
+* To-do lists
+* Parse natural language (e.g. `every friday at 2pm`) and crontab syntax
+* Remind just yourself or the whole room
+* Subscribe to other people's reminders
+* Lots of [locales](https://dateparser.readthedocs.io/en/latest/supported_locales.html)
+* Per-user rate limits
+* Maubot!
+
+## Setup
+This bot requires python libraries that aren't included in the official maubot docker image.
+
+* [apscheduler](https://github.com/agronholm/apscheduler)
+* [dateparser](https://github.com/scrapinghub/dateparser)
+* [cron_descriptor](https://github.com/Salamek/cron-descriptor) (optional, shows cron reminders with natural language)
+* [arrow](https://github.com/arrow-py/arrow) (optional, allows more advanced displays of time ranges)
+
+Be sure to add them to the optional-requirements.txt file in [maubot](https://github.com/maubot/maubot) and build a new docker image with
+`docker build --tag maubot-for-reminders . -f Dockerfile`
+
+This is pretty easy to use with the [ansible deployment](https://github.com/spantaleev/matrix-docker-ansible-deploy), just add this line to your vars.yml: `matrix_bot_maubot_docker_image: maubot-for-reminders`
 
 ## Usage
-Use `!remind <date> <message>` to set a reminder. To subscribe to a reminder set by someone else,
-upvote the message with a 👍 reaction. To cancel a reminder, remove the message or reaction.
-After the reminder fires, you can re-schedule it with `!reminder again <date>`.
+### Creating optionally recurring reminders:
+`!remind <message containing date>` Adds a reminder by extracting the date from the text
+* `!remind abolish closed-access journals at 3pm tomorrow`
+* `!remind 8 hours buy more pumpkins`
+* `!remind 2023-11-30 15:00 befriend rats`
+
+`!remind <date>; <message>` Bypasses text parsing by explicitly specifying the date
+* `!remind 2 days 4 hours; do something`
+
+`!remind [room] [every] ...`
+* `[room]` pings the whole room
+* `[every]` create recurring reminders `!remind every friday 3pm take out the trash`
+
+`!remind [room] <cron> <message>` Schedules a reminder using a crontab syntax
+* `!remind cron 30 9 * * mon-fri do something` sets reminders for 9:30am, Monday through Friday.
+* `!remind cron` lists more examples
+
+You can also reply to any message with `!remind ...` to get reminded about that message.\\
+To get pinged by someone else's reminder, react to their message with 👍.
+
+### Creating agenda items
+`!agenda [room] <message>` creates an agenda item. Agenda items are like reminders but don't have a time, for things like to-do lists.
+
+### Listing active reminders
+`!remind list [all] [my] [subscribed]` lists all reminders in a room 
+* `all` lists all reminders from every room
+* `my` lists only reminders you created
+* `subscribed` lists only reminders you are subscribed to
+
+### Deleting reminders
+Cancel reminders by removing the message creating it, unsubscribe by removing your upvote.\\
+Cancel recurring reminders by replying to the ping with `!remind cancel|delete` 
+* `!remind cancel|delete <ID>` deletes a reminder matching the 4 letter ID shown by `list`
+* `!remind cancel|delete <message>` deletes a reminder *beginning with* <message>
+    * e.g. `!remind delete buy more` would delete the reminder `buy more pumpkins`
 
-`<date>` can be a time delta (e.g. `2 days 1.5 hours` or `friday at 15:00`)
-or an absolute date (e.g. `2020-03-27 15:00`).
+### Rescheduling
+Reminders can be rescheduled after they have fired by replying with `!remind <new date>`
 
-Note that subscribing to and cancelling reminders is only possible before the last minute.
-Each minute reminders that are scheduled to go off during that minute are sent to the event loop,
-at which point their target user list can no longer be updated.
+### Settings
+Dates are parsed using your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zone) and [locale](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
+* `!remind tz|timezone [new-timezone]` view or set your timezone
+* `!remind locale [new-locale]` view or set your locale
 
-To set the timezone for date parsing and output for your messages, use `!remind tz <timezone>`.
-It's recommended to use a [TZ database name](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones),
-but anything supported by Pytz will work.
+## Cron Syntax
+```
+*	any value
+,	value list separator
+-	range of values
+/	step 
 
-Similarly, you can set the locale for date parsing with `!remind locale <list of locales>`. If you
-provide multiple locales, each one will be tried for parsing your input until one matches. Unlike
-the timezone, the locale only affects input, not output. You can view available locales using
-`!remind locales`. You can also contribute new locales by making a pull request
-(see [locales.py](reminder/locales.py), content warning: long regexes).
+┌─────── minute (0 - 59)
+│ ┌─────── hour (0 - 23)
+│ │ ┌─────── day of the month (1 - 31)
+│ │ │ ┌─────── month (1 - 12)
+│ │ │ │ ┌─────── weekday (0 - 6) (Sunday to Saturday)                             
+│ │ │ │ │
+* * * * * <message>
+```
 
-To list your upcoming reminders, use `!remind list`
+```
+30 9 * * *              Every day at 9:30am
+0/30 9-17 * * mon-fri   Every 30 minutes from 9am to 5pm, Monday through Friday
+0 14 1,16 * *           2:00pm on the 1st and 16th day of the month
+0 0 1-7 * mon           First Monday of the month at midnight
\ No newline at end of file
diff --git a/base-config.yaml b/base-config.yaml
index ab9095faef3038c601d06f85352da56e938f3bc3..22a4eb698a1fbc18c534a9e91c5ba664b15ee4b7 100644
--- a/base-config.yaml
+++ b/base-config.yaml
@@ -1,9 +1,29 @@
 # Default timezone for users who did not set one.
-# This is parsed with pytz, so the usual format is Continent/City, e.g. Europe/Helsinki.
-default_timezone: 'UTC'
+# This is parsed with dateparser, so Continent/City, UTC offsets, and abbreviations should work.
+#  e.g. Europe/Helsinki, EET, +0300
+# See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
+default_timezone: America/Los_Angeles
+
+# Default locale/language compatible with dateparser
+# See https://dateparser.readthedocs.io/en/latest/supported_locales.html
+default_locale: en
 
 # Base command without the prefix (!).
 # If a list is provided, the first is the main name and the rest are aliases.
 base_command:
 - remind
-- reminder
+- remindme
+
+# Alias used to create an agenda items
+agenda_command:
+- agenda
+
+
+# If verbose is true, display full confirmation messages. If false, confirm by reacting with :thumbs-up:
+verbose: true
+
+# Rate limit for individual users, as the number of reminders allowed to fire in an interval
+rate_limit: 10
+rate_limit_minutes: 60
+
+admin_power_level: 50
diff --git a/maubot.yaml b/maubot.yaml
index edde9b3f32c2d91c669ac05e045d599c2c461cba..b8a7b5fc6c5017ed1934840bafc49d61a89f6723 100644
--- a/maubot.yaml
+++ b/maubot.yaml
@@ -1,6 +1,6 @@
 maubot: 0.4.1
-id: xyz.maubot.reminder
-version: 0.2.2
+id: org.bytemarx.reminder
+version: 0.1.0
 license: AGPL-3.0-or-later
 modules:
 - reminder
@@ -8,6 +8,12 @@ main_class: ReminderBot
 extra_files:
 - base-config.yaml
 dependencies:
-- python-dateutil
 - pytz
+- dateparser
+- apscheduler
+soft_dependencies:
+- cron_descriptor
+- arrow
 database: true
+database_type: asyncpg
+
diff --git a/reminder/bot.py b/reminder/bot.py
index b42224a5f7cb8542166904d6bdd8504df5a18334..dbd7872f2fd53cf381bee7b6acbfa8ff8d7d268d 100644
--- a/reminder/bot.py
+++ b/reminder/bot.py
@@ -1,4 +1,4 @@
-# reminder - A maubot plugin to remind you about things.
+# reminder - A maubot plugin to create_reminder you about things.
 # Copyright (C) 2020 Tulir Asokan
 #
 # This program is free software: you can redistribute it and/or modify
@@ -13,273 +13,412 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Type, Tuple, List
+import re
+from typing import Type, Tuple, List, Dict
 from datetime import datetime, timedelta
-from html import escape
-import asyncio
-
 import pytz
 
 from mautrix.types import (EventType, RedactionEvent, StateEvent, Format, MessageType,
-                           TextMessageEventContent, ReactionEvent, UserID)
-from mautrix.util.config import BaseProxyConfig
-from mautrix.util import background_task
+                           TextMessageEventContent, ReactionEvent, UserID, EventID, RelationType)
 from maubot import Plugin, MessageEvent
 from maubot.handlers import command, event
+from mautrix.util.async_db import UpgradeTable
+from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
 
+from .migrations import upgrade_table
 from .db import ReminderDatabase
-from .util import Config, ReminderInfo, DateArgument, parse_timezone, format_time
-from .locales import locales
+from .util import validate_locale, validate_timezone, CommandSyntaxError, parse_date, CommandSyntax
+from .reminder import Reminder
+from apscheduler.schedulers.asyncio import AsyncIOScheduler
+
+# TODO: merge licenses
 
+class Config(BaseProxyConfig):
+    def do_update(self, helper: ConfigUpdateHelper) -> None:
+        helper.copy("default_timezone")
+        helper.copy("default_locale")
+        helper.copy("base_command")
+        helper.copy("agenda_command")
+        helper.copy("rate_limit_minutes")
+        helper.copy("rate_limit")
+        helper.copy("verbose")
+        helper.copy("admin_power_level")
 
 class ReminderBot(Plugin):
-    db: ReminderDatabase
-    reminder_loop_task: asyncio.Future
     base_command: str
     base_aliases: Tuple[str, ...]
+    agenda_command: Tuple[str, ...]
     default_timezone: pytz.timezone
+    scheduler: AsyncIOScheduler
+    reminders: Dict[EventID, Reminder]
+    db: ReminderDatabase
 
     @classmethod
     def get_config_class(cls) -> Type[BaseProxyConfig]:
         return Config
 
+    @classmethod
+    def get_db_upgrade_table(cls) -> UpgradeTable:
+        return upgrade_table
+
+
+
     async def start(self) -> None:
-        self.on_external_config_update()
+        self.scheduler = AsyncIOScheduler()
+        # self.scheduler.configure({"apscheduler.timezone": self.config["default_timezone"]})
+        self.scheduler.start()
         self.db = ReminderDatabase(self.database)
-        self.reminder_loop_task = asyncio.create_task(self.reminder_loop())
+        self.on_external_config_update()
+        # load all reminders
+        self.reminders = await self.db.load_all(self)
 
     def on_external_config_update(self) -> None:
         self.config.load_and_update()
         bc = self.config["base_command"]
+        ac = self.config["agenda_command"]
         self.base_command = bc[0] if isinstance(bc, list) else bc
         self.base_aliases = tuple(bc) if isinstance(bc, list) else (bc,)
-        raw_timezone = self.config["default_timezone"]
-        try:
-            self.default_timezone = pytz.timezone(raw_timezone)
-        except pytz.UnknownTimeZoneError:
-            self.log.warning(f"Unknown default timezone {raw_timezone}")
-            self.default_timezone = pytz.UTC
+        self.agenda_command = tuple(ac) if isinstance(ac, list) else (ac,)
+
+        # If the locale or timezone is invalid, use default one
+        self.db.defaults.locale = self.config["default_locale"]
+        if not validate_locale(self.config["default_locale"]):
+            self.log.warning(f'unknown default locale: {self.config["default_locale"]}')
+            self.db.defaults.locale = "en"
+        self.db.defaults.timezone = self.config["default_timezone"]
+        if not validate_timezone(self.config["default_timezone"]):
+            self.log.warning(f'unknown default timezone: {self.config["default_timezone"]}')
+            self.db.defaults.timezone = "UTC"
+
 
     async def stop(self) -> None:
-        self.reminder_loop_task.cancel()
+        self.scheduler.shutdown(wait=False)
 
-    async def reminder_loop(self) -> None:
-        try:
-            self.log.debug("Reminder loop started")
-            while True:
-                now = datetime.now(tz=pytz.UTC)
-                next_minute = (now + timedelta(minutes=1)).replace(second=0, microsecond=0)
-                await asyncio.sleep((next_minute - now).total_seconds())
-                await self.schedule_nearby_reminders(next_minute)
-        except asyncio.CancelledError:
-            self.log.debug("Reminder loop stopped")
-        except Exception:
-            self.log.exception("Exception in reminder loop")
-
-    async def schedule_nearby_reminders(self, now: datetime) -> None:
-        until = now + timedelta(minutes=1)
-        for reminder in self.db.all_in_range(now, until):
-            background_task.create(self.send_reminder(reminder))
-
-    async def send_reminder(self, reminder: ReminderInfo) -> None:
-        try:
-            await self._send_reminder(reminder)
-        except Exception:
-            self.log.exception("Failed to send reminder")
 
-    async def _send_reminder(self, reminder: ReminderInfo) -> None:
-        if len(reminder.users) == 0:
-            self.log.debug(f"Cancelling reminder {reminder}, no users left to remind")
-            return
-        wait = (reminder.date - datetime.now(tz=pytz.UTC)).total_seconds()
-        if wait > 0:
-            self.log.debug(f"Waiting {wait} seconds to send {reminder}")
-            await asyncio.sleep(wait)
-        else:
-            self.log.debug(f"Sending {reminder} immediately")
-        users = " ".join(reminder.users)
-        users_html = " ".join(f"<a href='https://matrix.to/#/{user_id}'>{user_id}</a>"
-                              for user_id in reminder.users)
-        content = TextMessageEventContent(
-            msgtype=MessageType.TEXT, body=f"{users}: {reminder.message}", format=Format.HTML,
-            formatted_body=f"{users_html}: {escape(reminder.message)}")
-        content["xyz.maubot.reminder"] = {
-            "id": reminder.id,
-            "message": reminder.message,
-            "targets": list(reminder.users),
-            "reply_to": reminder.reply_to,
-        }
-        if reminder.reply_to:
-            content.set_reply(await self.client.get_event(reminder.room_id, reminder.reply_to))
-        await self.client.send_message(reminder.room_id, content)
 
     @command.new(name=lambda self: self.base_command,
-                 aliases=lambda self, alias: alias in self.base_aliases,
+                 aliases=lambda self, alias: alias in self.base_aliases + self.agenda_command,
                  help="Create a reminder", require_subcommand=False, arg_fallthrough=False)
-    @DateArgument("date", required=True)
+    @command.argument("room", matches="room", required=False)
+    @command.argument("every", matches="every", required=False)
+    @command.argument("start_time", matches="(.*?);", pass_raw=True, required=False)
+    @command.argument("cron", matches="cron ?(?:\s*\S*){0,5}", pass_raw=True, required=False)
     @command.argument("message", pass_raw=True, required=False)
-    async def remind(self, evt: MessageEvent, date: datetime, message: str) -> None:
-        date = date.replace(microsecond=0)
-        now = datetime.now(tz=pytz.UTC).replace(microsecond=0)
-        if date < now:
-            await evt.reply(f"Sorry, {date} is in the past and I don't have a time machine :(")
-            return
-        rem = ReminderInfo(date=date, room_id=evt.room_id, message=message,
-                           reply_to=evt.content.get_reply_to(), users={evt.sender: evt.event_id})
-        await self._remind(evt, rem, now)
-
-    @remind.subcommand("reschedule", help="Reschedule a reminder you got", aliases=("again",))
-    @DateArgument("date", required=True)
-    async def reschedule(self, evt: MessageEvent, date: datetime) -> None:
+    async def create_reminder(self, evt: MessageEvent,
+                              room: str = None,
+                              every: str = None,
+                              start_time: Tuple[str] = None,
+                              cron: Tuple[str] = None,
+                              message: str = None,
+                              again: bool = False) -> None:
+        """Create a reminder or an alarm with a given target
+        Args:
+            evt:
+            room:
+            cron:
+            every:
+            start_time:
+            message:
+            again:
+        """
+        date_str = None
         reply_to_id = evt.content.get_reply_to()
-        if not reply_to_id:
-            await evt.reply("You must reply to a reminder event to reschedule it.")
-            return
-        date = date.replace(microsecond=0)
-        now = datetime.now(tz=pytz.UTC).replace(microsecond=0)
-        if date < now:
-            await evt.reply(f"Sorry, {date} is in the past and I don't have a time machine :(")
-            return
-        reply_to = await self.client.get_event(evt.room_id, reply_to_id)
+        user_info = await self.db.get_user_info(evt.sender)
+        # Determine is the agenda command was used instead of creating a subcommand so [room] can still be used
+        agenda = evt.content.body[1:].startswith(self.agenda_command)
+        if agenda:
+            # Use the date the message was created as the date for agenda items
+            start_time = datetime.now(tz=pytz.UTC)
+
+        # If we are replying to a previous reminder, recreate the original reminder with a new time
+        if reply_to_id:
+            reply_to = await self.client.get_event(room_id=evt.room_id, event_id=reply_to_id)
+            if "org.bytemarx.reminder" in reply_to.content:
+                again = True
+                start_time = (message,)
+                message = reply_to.content["org.bytemarx.reminder"]["message"]
+                reply_to_id = reply_to.content["org.bytemarx.reminder"]["reply_to"]
+                event_id = reply_to.content["org.bytemarx.reminder"]["id"]
+                if event_id in self.reminders:
+                    await self.reminders[event_id].cancel()
+
         try:
-            reminder_info = reply_to.content["xyz.maubot.reminder"]
-            rem = ReminderInfo(date=date, room_id=evt.room_id, message=reminder_info["message"],
-                               reply_to=reminder_info["reply_to"], users={evt.sender: evt.event_id})
-        except KeyError:
-            await evt.reply("That doesn't look like a valid reminder event.")
+            if not cron and not agenda:
+                if start_time:
+                    start_time, date_str = parse_date(start_time[0], user_info)
+                elif message.strip(): # extract the date from the message if not explicitly given
+                    start_time, date_str = parse_date(message, user_info, search_text=True)
+
+                    # Check if "every" appears immediately before the date, if so, the reminder should be recurring.
+                    # This makes "every" possible to use in a sentence instead of just with @command.argument("every")
+                    if not every:
+                        every = message.lower().find('every ' + date_str.lower()) >= 0
+
+                    # Remove the date from the messages, converting "buy ice cream on monday" to "buy ice cream"
+                    compiled = re.compile("(every )?" + re.escape(date_str), re.IGNORECASE)
+                    message = compiled.sub("", message,  1).strip()
+
+                else: # If no arguments are supplied, return the help message
+                    await evt.reply(self._help_message())
+                    return
+            reminder = Reminder(
+                bot=self,
+                room_id=evt.room_id,
+                message=message,
+                event_id=evt.event_id,
+                reply_to=reply_to_id,
+                start_time=start_time,
+                cron_tab=cron,
+                recur_every=date_str if every else None,
+                is_agenda=agenda,
+                creator=evt.sender,
+                user_info=user_info,
+            )
+
+        except CommandSyntaxError as e:
+            await evt.reply(e.message)
             return
-        await self._remind(evt, rem, now, again=True)
 
-    async def _remind(self, evt: MessageEvent, rem: ReminderInfo, now: datetime, again: bool = False
-                      ) -> None:
-        if rem.date == now:
-            await self.send_reminder(rem)
+        # Record the reminder and subscribe to it
+        await self.db.store_reminder(reminder)
+
+        # If the command was called with a "room_command", make the reminder ping the room
+        user_id = UserID("@room") if room else evt.sender
+        await reminder.add_subscriber(subscribing_event=evt.event_id, user_id=user_id)
+
+        # Send a message to the room confirming the creation of the reminder
+        await self.confirm_reminder(evt, reminder, again=again, agenda=agenda)
+
+        self.reminders[reminder.event_id] = reminder
+
+
+    async def confirm_reminder(self, evt: MessageEvent, reminder: Reminder, again: bool = False, agenda: bool = False):
+        """Sends a message to the room confirming the reminder is set
+        If verbose is set in the config, print out the full message. If false, just react with 👍
+
+        Args:
+            evt:
+            reminder: The Reminder to confirm
+            again: Is this a reminder that was rescheduled?
+            agenda: Is this an agenda instead of a reminder?
+        """
+        if self.config["verbose"]:
+
+            action = "add this to the agenda for" if agenda else "remind" if reminder.message else "ping"
+            target = "the room" if "@room" in reminder.subscribed_users.values() else "you"
+            message = f"to {reminder.message}" if reminder.message else ""
+
+            if reminder.reply_to:
+                evt_link = f"[message](https://matrix.to/#/{reminder.room_id}/{reminder.reply_to})"
+                message += f" (replying to that {evt_link})" if reminder.message else f" about that {evt_link}"
+
+            msg = f"I'll {action} {target} {message}"
+            msg += " again" if again else ""
+
+            if again:
+                msg += " again"
+            if not agenda:
+                formatted_time = reminder.formatted_time(await self.db.get_user_info(evt.sender))
+                msg += " " + formatted_time
+
+
+            confirmation_event = await evt.reply(f"{msg}\n\n"
+                            f"(others can \U0001F44D the message above to get pinged too)")
+        else:
+            confirmation_event = await evt.react("\U0001F44D")
+        await reminder.set_confirmation(confirmation_event)
+
+
+    # @command.new("cancel", help="Cancel a recurring reminder", aliases=("delete",))
+    @create_reminder.subcommand("cancel", help="Cancel a recurring reminder", aliases=("delete",))
+    @command.argument("search_text", pass_raw=True, required=False)
+    async def cancel_reminder(self, evt: MessageEvent, search_text: str) -> None:
+        """Cancel a reminder by replying to a reminder, or searching by either message or event ID"""
+
+        reminder = []
+        if evt.content.get_reply_to():
+            reminder_message = await self.client.get_event(evt.room_id, evt.content.get_reply_to())
+            if "org.bytemarx.reminder" not in reminder_message.content:
+                await evt.reply("That doesn't look like a valid reminder event.")
+                return
+            reminder = self.reminders[reminder_message.content["org.bytemarx.reminder"]["id"]]
+        elif search_text:
+            # First, check the reminders created by the user, then everything else
+            for rem in sorted(self.reminders.values(), key=lambda x: x.creator == evt.sender, reverse=True):
+                # Using the first four base64 digits of the hash, p(collision) > 0.01 at ~10000 reminders
+                if rem.event_id[1:5] == search_text or re.match(re.escape(search_text.strip()), rem.message.strip(), re.IGNORECASE):
+                # if rem.event_id[1:5] == search_text or rem.message.upper().strip() == search_text.upper().strip():
+                    reminder = rem
+                    break
+        else: # Display the help message
+            await evt.reply(CommandSyntax.REMINDER_CANCEL.value)
+            return
+
+        if not reminder:
+            await evt.reply(f"It doesn't look like you have any reminders matching the text `{search_text}`")
             return
-        remind_type = "remind you "
-        if rem.reply_to:
-            evt_link = f"[event](https://matrix.to/#/{rem.room_id}/{rem.reply_to})"
-            if rem.message:
-                remind_type += f"to {rem.message} (replying to that {evt_link})"
-            else:
-                remind_type += f"about that {evt_link}"
-        elif rem.message:
-            remind_type += f"to {rem.message}"
+
+        power_levels = await self.client.get_state_event(room_id=reminder.room_id,event_type=EventType.ROOM_POWER_LEVELS)
+        user_power = power_levels.users.get(evt.sender, power_levels.users_default)
+
+        if reminder.creator == evt.sender or user_power >= self.config["admin_power_level"]:
+            await reminder.cancel()
+            await evt.reply("Reminder cancelled!") if self.config["verbose"] else await evt.react("👍")
         else:
-            remind_type = "ping you"
-            rem.message = "ping"
-        if again:
-            remind_type += " again"
-        msg = (f"I'll {remind_type} {self.format_time(evt.sender, rem)}.\n\n"
-               f"(others can \U0001F44D this message to get pinged too)")
-        rem.event_id = await evt.reply(msg)
-        self.db.insert(rem)
-        now = datetime.now(tz=pytz.UTC)
-        if (rem.date - now).total_seconds() < 60 and now.minute == rem.date.minute:
-            self.log.debug(f"Reminder {rem} is in less than a minute, scheduling now...")
-            background_task.create(self.send_reminder(rem))
-
-    @remind.subcommand("help", help="Usage instructions")
+            await evt.reply(f"Power levels of {self.config['admin_power_level']} are required to cancel other people's reminders")
+
+
+    @create_reminder.subcommand("help", help="Usage instructions")
     async def help(self, evt: MessageEvent) -> None:
-        await evt.reply(f"Maubot [Reminder](https://github.com/maubot/reminder) plugin.\n\n"
-                        f"* !{self.base_command} <date> <message> - Add a reminder\n"
-                        f"* !{self.base_command} again <date> - Reply to a reminder to reschedule it\n"
-                        f"* !{self.base_command} list - Get a list of your reminders\n"
-                        f"* !{self.base_command} tz <timezone> - Set your time zone\n"
-                        f"* !{self.base_command} locale <locale> - Set your locale\n"
-                        f"* !{self.base_command} locales - List available locales\n\n"
-                        "<date> can be a time delta (e.g. `2 days 1.5 hours` or `friday at 15:00`) "
-                        "or an absolute date (e.g. `2020-03-27 15:00`)\n\n"
-                        "To get mentioned by a reminder added by someone else, upvote the message "
-                        "by reacting with \U0001F44D.\n\n"
-                        "To cancel a reminder, remove the message or reaction.")
-
-    @remind.subcommand("list", help="List your reminders")
-    @command.argument("all", required=False)
-    async def list(self, evt: MessageEvent, all: str) -> None:
-        room_id = evt.room_id
-        if "all" in all:
-            room_id = None
-
-        def format_rem(rem: ReminderInfo) -> str:
-            if rem.reply_to:
-                evt_link = f"[event](https://matrix.to/#/{rem.room_id}/{rem.reply_to})"
-                if rem.message:
-                    return f'"{rem.message}" (replying to {evt_link})'
+        await evt.reply(self._help_message(), allow_html=True)
+
+
+    @create_reminder.subcommand("list", help="List your reminders")
+    @command.argument("my", parser=lambda x: (re.sub(r"\bmy\b", "", x), re.search(r"\bmy\b", x)), required=False, pass_raw=True) # I hate it but it makes arguments not positional
+    @command.argument("subscribed", parser=lambda x: (re.sub(r"\bsubscribed\b", "", x), re.search(r"\bsubscribed\b", x)), required=False, pass_raw=True)
+    @command.argument("all", parser=lambda x: (re.sub(r"\ball\b", "", x), re.search(r"\ball\b", x)), required=False, pass_raw=True)
+    async def list(self, evt: MessageEvent, all: str, subscribed: str, my: str) -> None:
+        """Print out a formatted list of all the reminders for a user
+
+        Args:
+            evt: message event
+            my:  only list reminders the user created
+            all: list all reminders in every room
+            subscribed: only list reminders the user is subscribed to
+        """
+        room_id = None if all else evt.room_id
+        user_info = await self.db.get_user_info(evt.sender)
+        categories = {"**📜 Agenda items**": [], '**📅 Cron reminders**': [], '**🔁 Repeating reminders**': [], '**1️⃣ One-time reminders**': []}
+
+        # Sort the reminders by their next run date and format as bullet points
+        for reminder in sorted(self.reminders.values(), key=lambda x: x.job.next_run_time if x.job else datetime(2000,1,1,tzinfo=pytz.UTC)):
+            if (
+                    (not subscribed or any(x in reminder.subscribed_users.values() for x in [evt.sender, "@room"])) and
+                    (not my or evt.sender == reminder.creator) and
+                    (all or reminder.room_id == room_id)):
+
+                message = reminder.message
+                next_run = reminder.formatted_time(user_info)
+                short_event_id = f"[`{reminder.event_id[1:5]}`](https://matrix.to/#/{reminder.room_id}/{reminder.event_id})"
+
+                if reminder.reply_to:
+                    evt_link = f"[event](https://matrix.to/#/{reminder.room_id}/{reminder.reply_to})"
+                    message = f'{message} (replying to {evt_link})' if message else evt_link
+
+                if reminder.cron_tab:
+                    category = "**📅 Cron reminders**"
+                elif reminder.recur_every:
+                    category = "**🔁 Repeating reminders**"
+                elif not reminder.is_agenda:
+                    category = "**1️⃣ One-time reminders**"
                 else:
-                    return evt_link
-            else:
-                return f'"{rem.message}"'
-
-        reminders_str = "\n".join(f"* {format_rem(reminder)} {self.format_time(evt.sender, reminder)}"
-                                  for reminder in self.db.all_for_user(evt.sender, room_id=room_id))
-        message = "upcoming reminders"
-        if room_id:
-            message += " in this room"
-        if len(reminders_str) == 0:
-            await evt.reply(f"You have no {message} :(")
-        else:
-            await evt.reply(f"Your {message}:\n\n{reminders_str}")
-
-    def format_time(self, sender: UserID, reminder: ReminderInfo) -> str:
-        return format_time(reminder.date.astimezone(self.db.get_timezone(sender)))
-
-    @remind.subcommand("locales", help="List available locales")
-    async def locales(self, evt: MessageEvent) -> None:
-        def _format_key(key: str) -> str:
-            language, country = key.split("_")
-            return f"{language.lower()}_{country.upper()}"
-
-        await evt.reply("Available locales:\n\n" +
-                        "\n".join(f"* `{_format_key(key)}` - {locale.name}"
-                                  for key, locale in locales.items()))
-
-    @staticmethod
-    def _fmt_locales(locale_ids: List[str]) -> str:
-        locale_names = [locales[id].name for id in locale_ids]
-        if len(locale_names) == 0:
-            return "unset"
-        elif len(locale_names) == 1:
-            return locale_names[0]
-        else:
-            return ", ".join(locale_names[:-1]) + " and " + locale_names[-1]
+                    category = "**📜 Agenda items**"
+                categories[category].append(f"* {short_event_id} {next_run}  **{message}**")
+
+        # Upack the nested dict into a flat list of reminders seperated by category
+        in_room_msg = " in this room" if room_id else ""
+        output = []
+        for category, reminders in categories.items():
+            if reminders:
+                output.append("\n" + category + in_room_msg)
+                for reminder in reminders:
+                    output.append(reminder)
+        output = "\n".join(output)
+
+        if not output:
+            output = f"You have no upcoming reminders{in_room_msg} :("
+        await evt.reply(output)
+
+
 
-    @remind.subcommand("locale", help="Set your locale")
+    @create_reminder.subcommand("locale", help="Set your locale")
     @command.argument("locale", required=False, pass_raw=True)
     async def locale(self, evt: MessageEvent, locale: str) -> None:
         if not locale:
-            await evt.reply(f"Your locale is {self._fmt_locales(self.db.get_locales(evt.sender))}")
+            await evt.reply(f"Your locale is `{(await self.db.get_user_info(evt.sender)).locale}`")
             return
-        locale_ids = [part.strip() for part in locale.lower().split(" ")]
-        for locale_id in locale_ids:
-            if locale_id not in locales:
-                await evt.reply(f"Locale `{locale_id}` is not supported")
-                return
-        self.db.set_locales(evt.sender, locale_ids)
-        await evt.reply(f"Set your locale to {self._fmt_locales(locale_ids)}")
+        if validate_locale(locale):
+            await self.db.set_user_info(evt.sender, key="locale", value=locale)
+            await evt.reply(f"Setting your locale to {locale}")
+        else:
+            await evt.reply(f"Unknown locale: `{locale}`\n\n"
+                            f"[Available locales](https://dateparser.readthedocs.io/en/latest/supported_locales.html)"
+                            f" (case sensitive)")
+
 
-    @remind.subcommand("timezone", help="Set your timezone", aliases=("tz",))
-    @command.argument("timezone", parser=parse_timezone, required=False)
+
+    @create_reminder.subcommand("timezone", help="Set your timezone", aliases=("tz",))
+    @command.argument("timezone", required=False, pass_raw=True)
     async def timezone(self, evt: MessageEvent, timezone: pytz.timezone) -> None:
         if not timezone:
-            await evt.reply(f"Your time zone is {self.db.get_timezone(evt.sender).zone}")
+            await evt.reply(f"Your timezone is `{(await self.db.get_user_info(evt.sender)).timezone}`")
             return
-        self.db.set_timezone(evt.sender, timezone)
-        await evt.reply(f"Set your timezone to {timezone.zone}")
+        if validate_timezone(timezone):
+            await self.db.set_user_info(evt.sender, key="timezone", value=timezone)
+            await evt.reply(f"Setting your timezone to {timezone}")
+        else:
+            await evt.reply(f"Unknown timezone: `{timezone}`\n\n"
+                            f"[Available timezones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones)")
+
 
     @command.passive(regex=r"(?:\U0001F44D[\U0001F3FB-\U0001F3FF]?)",
                      field=lambda evt: evt.content.relates_to.key,
                      event_type=EventType.REACTION, msgtypes=None)
     async def subscribe_react(self, evt: ReactionEvent, _: Tuple[str]) -> None:
-        reminder = self.db.get_by_event_id(evt.content.relates_to.event_id)
+        """
+        Subscribe to a reminder by reacting with "👍"️
+        """
+        reminder_id = evt.content.relates_to.event_id
+        reminder = self.reminders.get(reminder_id)
         if reminder:
-            self.db.add_user(reminder, evt.sender, evt.event_id)
+            await reminder.add_subscriber(user_id=evt.sender, subscribing_event=evt.event_id)
+
 
     @event.on(EventType.ROOM_REDACTION)
     async def redact(self, evt: RedactionEvent) -> None:
-        self.db.redact_event(evt.redacts)
+        """Unsubscribe from a reminder by redacting the message"""
+        for key, reminder in self.reminders.items():
+            if evt.redacts in reminder.subscribed_users:
+                await reminder.remove_subscriber(subscribing_event=evt.redacts)
+
+                # If the reminder has no users left, cancel it
+                if not reminder.subscribed_users or reminder.event_id == evt.redacts:
+                    await reminder.cancel(redact_confirmation=True)
+                break
+
 
     @event.on(EventType.ROOM_TOMBSTONE)
     async def tombstone(self, evt: StateEvent) -> None:
-        if not evt.content.replacement_room:
-            return
-        self.db.update_room_id(evt.room_id, evt.content.replacement_room)
+        """If a room gets upgraded or replaced, move any reminders to the new room"""
+        if evt.content.replacement_room:
+            await self.db.update_room_id(old_id=evt.room_id, new_id=evt.content.replacement_room)
+
+    def _help_message(self) -> str:
+        return f"""
+**⏰ Maubot [Reminder](https://github.com/maubot/reminder) plugin**\\
+TLDR: `!remind every friday 3pm take out the trash` `!remind cancel take out the trash`\\
+All commands can be called with `!{"|".join(self.base_aliases)}`
+
+**Creating optionally recurring reminders:**
+{CommandSyntax.REMINDER_CREATE.value.format(base_command=self.base_command)}
+
+**Creating agenda items**
+{CommandSyntax.AGENDA_CREATE.value.format(agenda_command="|".join(self.agenda_command))}
+
+**Listing active reminders**
+{CommandSyntax.REMINDER_LIST.value.format(base_command=self.base_command)}
+
+**Deleting reminders**
+
+{CommandSyntax.REMINDER_CANCEL.value.format(base_command=self.base_command)}
+
+**Rescheduling**
+
+{CommandSyntax.REMINDER_RESCHEDULE.value.format(base_command=self.base_command)}
+
+**Settings**
+
+{CommandSyntax.REMINDER_SETTINGS.value.format(base_command=self.base_command,
+                                              default_tz=self.db.defaults.timezone,
+                                              default_locale=self.db.defaults.locale)}
+"""
\ No newline at end of file
diff --git a/reminder/db.py b/reminder/db.py
index 2cce8e39ce9f29f78b7d21f7228ef798a3749be9..be60f75127d8b704eabbe5ba355016979cbd68ff 100644
--- a/reminder/db.py
+++ b/reminder/db.py
@@ -13,199 +13,222 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Optional, Iterator, Dict, List
+from __future__ import annotations
+import logging
+
+from typing import Optional, Iterator, Dict, List, DefaultDict
 from datetime import datetime
+from collections import defaultdict
+from .util import validate_timezone, validate_locale, UserInfo
 
 import pytz
-from sqlalchemy import (Column, String, Integer, Text, DateTime, ForeignKey, Table, MetaData,
-                        select, and_)
-from sqlalchemy.engine.base import Engine
+
+from mautrix.util.async_db import Database
 
 from mautrix.types import UserID, EventID, RoomID
+from typing import Dict, Literal, TYPE_CHECKING
+
+
+if TYPE_CHECKING:
+    from .bot import ReminderBot
+
+from .reminder import Reminder
 
-from .util import ReminderInfo
+
+logger = logging.getLogger(__name__)
 
 
 class ReminderDatabase:
-    reminder: Table
-    reminder_target: Table
-    timezone: Table
-    tz_cache: Dict[UserID, pytz.timezone]
-    locale_cache: Dict[UserID, List[str]]
-    db: Engine
-
-    def __init__(self, db: Engine) -> None:
+    cache: DefaultDict[UserID, UserInfo]
+    db: Database
+    defaults: UserInfo
+
+    def __init__(self, db: Database, defaults: UserInfo = UserInfo()) -> None:
         self.db = db
-        self.tz_cache = {}
-        self.locale_cache = {}
-
-        meta = MetaData()
-        meta.bind = db
-
-        self.reminder = Table("reminder", meta,
-                              Column("id", Integer, primary_key=True, autoincrement=True),
-                              Column("date", DateTime, nullable=False),
-                              Column("room_id", String(255), nullable=False),
-                              Column("event_id", String(255), nullable=False),
-                              Column("message", Text, nullable=False),
-                              Column("reply_to", String(255), nullable=True))
-        self.reminder_target = Table("reminder_target", meta,
-                                     Column("reminder_id", Integer,
-                                            ForeignKey("reminder.id", ondelete="CASCADE"),
-                                            primary_key=True),
-                                     Column("user_id", String(255), primary_key=True),
-                                     Column("event_id", String(255), nullable=False))
-        self.timezone = Table("timezone", meta,
-                              Column("user_id", String(255), primary_key=True),
-                              Column("timezone", String(255), nullable=False))
-        self.locale = Table("locale", meta,
-                            Column("user_id", String(255), primary_key=True),
-                            Column("locales", String(255), nullable=False))
-
-        meta.create_all()
-
-    def set_timezone(self, user_id: UserID, tz: pytz.timezone) -> None:
-        with self.db.begin() as tx:
-            tx.execute(self.timezone.delete().where(self.timezone.c.user_id == user_id))
-            tx.execute(self.timezone.insert().values(user_id=user_id, timezone=tz.zone))
-        self.tz_cache[user_id] = tz
-
-    def get_timezone(self, user_id: UserID, default_tz: Optional[pytz.timezone] = None
-                     ) -> Optional[pytz.timezone]:
-        try:
-            return self.tz_cache[user_id]
-        except KeyError:
-            rows = self.db.execute(select([self.timezone.c.timezone])
-                                   .where(self.timezone.c.user_id == user_id))
-            try:
-                self.tz_cache[user_id] = pytz.timezone(next(rows)[0])
-            except (pytz.UnknownTimeZoneError, StopIteration, IndexError):
-                self.tz_cache[user_id] = default_tz or pytz.UTC
-            return self.tz_cache[user_id]
-
-    def set_locales(self, user_id: UserID, locales: List[str]) -> None:
-        with self.db.begin() as tx:
-            tx.execute(self.locale.delete().where(self.locale.c.user_id == user_id))
-            tx.execute(self.locale.insert().values(user_id=user_id, locales=",".join(locales)))
-        self.locale_cache[user_id] = locales
-
-    def get_locales(self, user_id: UserID) -> List[str]:
-        try:
-            return self.locale_cache[user_id]
-        except KeyError:
-            rows = self.db.execute(select([self.locale.c.locales])
-                                   .where(self.locale.c.user_id == user_id))
-            try:
-                self.locale_cache[user_id] = next(rows)[0].split(",")
-            except (StopIteration, IndexError):
-                self.locale_cache[user_id] = ["en_iso"]
-            return self.locale_cache[user_id]
-
-    def all_for_user(self, user_id: UserID, room_id: Optional[RoomID] = None
-                     ) -> Iterator[ReminderInfo]:
-        where = [self.reminder.c.id == self.reminder_target.c.reminder_id,
-                 self.reminder_target.c.user_id == user_id,
-                 self.reminder.c.date > datetime.now(tz=pytz.UTC)]
-        if room_id:
-            where.append(self.reminder.c.room_id == room_id)
-        rows = self.db.execute(select([self.reminder]).where(and_(*where)))
-        for row in rows:
-            yield ReminderInfo(id=row[0], date=row[1].replace(tzinfo=pytz.UTC), room_id=row[2],
-                               event_id=row[3], message=row[4], reply_to=row[5], users=[user_id])
-
-    def get(self, id: int) -> Optional[ReminderInfo]:
-        return self._get_one(self.reminder.c.id == id)
-
-    def get_by_event_id(self, event_id: EventID) -> Optional[ReminderInfo]:
-        reminder = self._get_one(self.reminder.c.event_id == event_id)
-        if reminder:
-            return reminder
-        rows = self.db.execute(select([self.reminder_target.c.reminder_id])
-                               .where(self.reminder_target.c.event_id == event_id))
-        try:
-            reminder_id = int(next(rows)[0])
-            return self.get(reminder_id)
-        except (StopIteration, IndexError, ValueError):
-            return None
-
-    def _get_one(self, whereclause) -> Optional[ReminderInfo]:
-        rows = self.db.execute(select([self.reminder, self.reminder_target.c.user_id,
-                                       self.reminder_target.c.event_id]).where(
-            and_(whereclause, self.reminder_target.c.reminder_id == self.reminder.c.id)))
-        try:
-            first_row = next(rows)
-        except StopIteration:
-            return None
-        info = ReminderInfo(id=first_row[0], date=first_row[1].replace(tzinfo=pytz.UTC),
-                            room_id=first_row[2], event_id=first_row[3], message=first_row[4],
-                            reply_to=first_row[5], users={first_row[6]: first_row[7]})
-        for row in rows:
-            info.users[row[6]] = row[7]
-        return info
-
-    def _get_many(self, whereclause) -> Iterator[ReminderInfo]:
-        rows = self.db.execute(select([self.reminder, self.reminder_target.c.user_id,
-                                       self.reminder_target.c.event_id])
-                               .where(whereclause)
-                               .order_by(self.reminder.c.id, self.reminder.c.date))
-        building_reminder = None
+        self.cache = defaultdict()
+        self.defaults = defaults
+
+
+    async def get_user_info(self, user_id: UserID) -> UserInfo:
+        """ Get the timezone and locale for a user. Data is cached in memory.
+        Args:
+            user_id: ID for the user to query
+        Returns:
+            UserInfo: a dataclass with keys: 'locale' and 'timezone'
+        """
+        if user_id not in self.cache:
+            query = "SELECT timezone, locale FROM user_settings WHERE user_id = $1"
+            row = dict(await self.db.fetchrow(query, user_id) or {})
+
+            locale = row.get("locale", self.defaults.locale)
+            timezone = row.get("timezone", self.defaults.timezone)
+
+            # If fetched locale is invalid, use default one
+            if not locale or not validate_locale(locale):
+                locale = self.defaults.locale
+
+            # If fetched timezone is invalid, use default one
+            if not timezone or not validate_timezone(timezone):
+                timezone = self.defaults.timezone
+
+            self.cache[user_id] = UserInfo(locale=locale, timezone=timezone)
+
+        return self.cache[user_id]
+
+
+    async def set_user_info(self, user_id: UserID, key: Literal["timezone", "locale"], value: str) -> None:
+        # Make sure user_info is populated first
+        await self.get_user_info(user_id)
+        # Update cache
+        setattr(self.cache[user_id], key, value)
+        # Update the db
+        q = """
+        INSERT INTO user_settings (user_id, {0}) 
+        VALUES ($1, $2) ON CONFLICT (user_id) DO UPDATE SET {0} = EXCLUDED.{0}
+        """.format(key)
+        await self.db.execute(q, user_id, value)
+
+    async def store_reminder(self, reminder: Reminder) -> None:
+        """Add a new reminder in the database"""
+        # hella messy but I don't know what else to do that works with both asyncpg and aiosqlite
+        await self.db.execute("""
+        INSERT INTO reminder (
+        event_id,
+        room_id,
+        start_time,
+        message,
+        reply_to,
+        cron_tab,
+        recur_every,
+        is_agenda,
+        creator
+        ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
+        """,
+            reminder.event_id,
+            reminder.room_id,
+            reminder.start_time.replace(microsecond=0).isoformat() if reminder.start_time else None,
+            reminder.message,
+            reminder.reply_to,
+            reminder.cron_tab,
+            reminder.recur_every,
+            reminder.is_agenda,
+            reminder.creator)
+
+
+    async def load_all(self, bot: ReminderBot) -> Dict[EventID, Reminder]:
+        """ Load all reminders in the database and return them as a dict for the main bot
+        Args:
+            bot: it feels dirty to do it this way, but it seems to work and make code cleaner but feel free to fix this
+        Returns:
+            a dict of Reminders, with the event id as the key.
+        """
+        rows = await self.db.fetch("""
+                SELECT
+                    event_id,
+                    room_id,
+                    message,
+                    reply_to,
+                    start_time,
+                    recur_every,
+                    cron_tab,
+                    is_agenda,
+                    user_id,
+                    confirmation_event,
+                    subscribing_event,
+                    creator
+                FROM reminder NATURAL JOIN reminder_target
+            """)
+        logger.debug(f"Loaded {len(rows)} reminders")
+        reminders = {}
         for row in rows:
-            if building_reminder is not None:
-                if building_reminder.id == row[0]:
-                    building_reminder.users[row[6]] = row[7]
-                    continue
-                yield building_reminder
-            building_reminder = ReminderInfo(id=row[0], date=row[1].replace(tzinfo=pytz.UTC),
-                                             room_id=row[2], event_id=row[3], message=row[4],
-                                             reply_to=row[5], users={row[6]: row[7]})
-        if building_reminder is not None:
-            yield building_reminder
-
-    def all(self) -> Iterator[ReminderInfo]:
-        yield from self._get_many(self.reminder_target.c.reminder_id == self.reminder.c.id)
-
-    def all_in_range(self, after: datetime, before: datetime) -> Iterator[ReminderInfo]:
-        yield from self._get_many(and_(self.reminder_target.c.reminder_id == self.reminder.c.id,
-                                       after <= self.reminder.c.date,
-                                       self.reminder.c.date < before))
-
-    def insert(self, reminder: ReminderInfo) -> None:
-        with self.db.begin() as tx:
-            res = tx.execute(self.reminder.insert()
-                             .values(date=reminder.date, room_id=reminder.room_id,
-                                     event_id=reminder.event_id, message=reminder.message,
-                                     reply_to=reminder.reply_to))
-            reminder.id = res.inserted_primary_key[0]
-            tx.execute(self.reminder_target.insert(),
-                       [{"reminder_id": reminder.id, "user_id": user_id,
-                         "event_id": event_id}
-                        for user_id, event_id in reminder.users.items()])
-
-    def update_room_id(self, old: RoomID, new: RoomID) -> None:
-        self.db.execute(self.reminder.update()
-                        .where(self.reminder.c.room_id == old)
-                        .values(room_id=new))
-
-    def redact_event(self, event_id: EventID) -> None:
-        self.db.execute(self.reminder_target.delete()
-                        .where(self.reminder_target.c.event_id == event_id))
-
-    def add_user(self, reminder: ReminderInfo, user_id: UserID, event_id: EventID) -> bool:
-        if user_id in reminder.users:
-            return False
-        self.db.execute(self.reminder_target.insert()
-                        .values(reminder_id=reminder.id, user_id=user_id, event_id=event_id))
-        if isinstance(reminder.users, list):
-            reminder.users.append(user_id)
-        elif isinstance(reminder.users, dict):
-            reminder.users[user_id] = event_id
-        return True
-
-    def remove_user(self, reminder: ReminderInfo, user_id: UserID) -> bool:
-        if user_id not in reminder.users:
-            return False
-        self.db.execute(self.reminder_target.delete().where(
-            and_(self.reminder_target.c.reminder_id == reminder.id,
-                 self.reminder_target.c.user_id == user_id)))
-        reminder.users.remove(user_id)
-        return True
+            # Reminder subscribers are stored in a separate table instead of in an array type for sqlite support
+            if row["event_id"] in reminders:
+                # reminders[row["event_id"]].subscribed_users.append(row["user_id"])
+                reminders[row["event_id"]].subscribed_users[row["user_id"]] = row["subscribing_event"]
+                continue
+
+            start_time = datetime.fromisoformat(row["start_time"]) if row["start_time"] else None
+
+            if start_time and not row["is_agenda"]:
+                # If this is a one-off reminder whose start time is in the past, then it will
+                # never fire. Ignore and delete the row from the db
+                if not row["recur_every"] and not row["cron_tab"]:
+                    now = datetime.now(tz=pytz.UTC)
+
+                    if start_time < now:
+                        logger.warning(
+                            "Deleting missed reminder in room %s: %s - %s",
+                            row["room_id"],
+                            row["start_time"],
+                            row["message"],
+                        )
+                        await self.delete_reminder(row["event_id"])
+                        continue
+
+            reminders[row["event_id"]] = Reminder(
+                bot=bot,
+                event_id=row["event_id"],
+                room_id=row["room_id"],
+                message=row["message"],
+                reply_to=row["reply_to"],
+                start_time=start_time,
+                recur_every=row["recur_every"],
+                cron_tab=row["cron_tab"],
+                is_agenda=row["is_agenda"],
+                subscribed_users={row["subscribing_event"]: row["user_id"]},
+                creator=row["creator"],
+                user_info= await self.get_user_info(row["creator"]),
+                confirmation_event=row["confirmation_event"],
+            )
+
+        return reminders
+
+    async def delete_reminder(self, event_id: EventID):
+        await self.db.execute(
+            """
+            DELETE FROM reminder WHERE event_id = $1
+        """,
+            event_id
+        )
+
+    async def reschedule_reminder(self, start_time: datetime, event_id: EventID):
+        await self.db.execute(
+            """
+            UPDATE reminder SET start_time=$1 WHERE event_id=$2
+        """,
+            start_time.replace(microsecond=0).isoformat(),
+            event_id
+        )
+    async def update_room_id(self, old_id: RoomID, new_id: RoomID) -> None:
+        await self.db.execute("""
+            UPDATE reminder
+            SET room_id = $1
+            WHERE room_id = $2
+        """,
+            new_id,
+            old_id)
+
+    async def add_subscriber(self, reminder_id: EventID, user_id: UserID, subscribing_event: EventID) -> None:
+        await self.db.execute("""
+        INSERT INTO reminder_target (event_id, user_id, subscribing_event) VALUES ($1, $2, $3)
+        """,
+            reminder_id,
+            user_id,
+            subscribing_event)
+
+    async def remove_subscriber(self, subscribing_event: EventID):
+        """Remove a user's subscription from a reminder"""
+        await self.db.execute("""
+        DELETE FROM reminder_target WHERE subscribing_event = $1
+        """,
+            subscribing_event
+        )
+
+    async def set_confirmation_event(self, event_id: EventID, confirmation_event: EventID):
+        await self.db.execute("""
+            UPDATE reminder
+            SET confirmation_event = $1
+            WHERE event_id = $2
+        """,
+            confirmation_event,
+            event_id)
\ No newline at end of file
diff --git a/reminder/locale_util.py b/reminder/locale_util.py
deleted file mode 100644
index 46e9041ee16a4ed4d23d48f29d4d54d275c1b99e..0000000000000000000000000000000000000000
--- a/reminder/locale_util.py
+++ /dev/null
@@ -1,182 +0,0 @@
-# reminder - A maubot plugin to remind you about things.
-# Copyright (C) 2020 Tulir Asokan
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import NamedTuple, Union, Pattern, Match, Dict, Type, Optional, TYPE_CHECKING
-from datetime import datetime
-from abc import ABC, abstractmethod
-import re
-
-from dateutil.relativedelta import MO
-
-WeekdayType = type(MO)
-
-if TYPE_CHECKING:
-    from typing import TypedDict
-
-
-    class RelativeDeltaParams(TypedDict):
-        year: int
-        month: int
-        day: int
-        hour: int
-        minute: int
-        second: int
-        microsecond: int
-
-        years: Union[int, float]
-        months: Union[int, float]
-        weeks: Union[int, float]
-        days: Union[int, float]
-        hours: Union[int, float]
-        minutes: Union[int, float]
-        seconds: Union[int, float]
-        microseconds: Union[int, float]
-
-        weekday: Union[int, WeekdayType]
-
-        leapdays: int
-        yearday: int
-        nlyearday: int
-
-
-class MatcherReturn(NamedTuple):
-    params: 'RelativeDeltaParams'
-    unconsumed: str
-
-
-class Matcher(ABC):
-    @abstractmethod
-    def match(self, val: str) -> Optional[MatcherReturn]:
-        pass
-
-
-def int_or_float(val: str) -> Union[int, float]:
-    if "," in val:
-        return float(val.replace(",", "."))
-    elif "." in val:
-        return float(val)
-    return int(val)
-
-
-class RegexMatcher(Matcher):
-    regex: Pattern
-    value_type: Type
-
-    def __init__(self, pattern: str, value_type: Type = int_or_float) -> None:
-        self.regex = re.compile(pattern, re.IGNORECASE)
-        self.value_type = value_type
-
-    def match(self, val: str) -> Optional[MatcherReturn]:
-        match = self.regex.match(val)
-        if match and match.end() > 0 and len(match.groups()) > 0:
-            return MatcherReturn(params=self._convert_match(match), unconsumed=val[match.end():])
-        return None
-
-    def _convert_match(self, match: Match) -> 'RelativeDeltaParams':
-        return self._convert_groups(match.groupdict())
-
-    def _convert_groups(self, groups: Dict[str, str]) -> 'RelativeDeltaParams':
-        return {key: self.value_type(value) for key, value in groups.items() if value}
-
-
-class TimeMatcher(RegexMatcher):
-    def _convert_match(self, match: Match) -> 'RelativeDeltaParams':
-        groups = match.groupdict()
-        try:
-            meridiem = groups.pop("meridiem").lower()
-        except (KeyError, AttributeError):
-            meridiem = None
-        params = self._convert_groups(groups)
-        if meridiem == "pm":
-            params["hour"] += 12
-        elif meridiem == "am" and params["hour"] == 12:
-            params["hour"] = 0
-        return params
-
-
-class ShortYearMatcher(RegexMatcher):
-    def _convert_match(self, match: Match) -> 'RelativeDeltaParams':
-        params = super()._convert_match(match)
-        if params["year"] < 100:
-            year = datetime.now().year
-            current_century = year // 100
-            if params["year"] < year % 100:
-                current_century += 1
-            params["year"] = (current_century * 100) + params["year"]
-        return params
-
-
-class WeekdayMatcher(Matcher):
-    regex: Pattern
-    map: Dict[str, Union[int, WeekdayType]]
-    substr: int
-
-    def __init__(self, pattern: str, map: Dict[str, Union[int, WeekdayType]], substr: int) -> None:
-        self.regex = re.compile(pattern, re.IGNORECASE)
-        self.map = map
-        self.substr = substr
-
-    def match(self, val: str) -> Optional[MatcherReturn]:
-        match = self.regex.match(val)
-        if match and match.end() > 0:
-            weekday = self.map[match.string[:self.substr].lower()]
-            if isinstance(weekday, int):
-                weekday = (datetime.now().weekday() + weekday) % 7
-            return MatcherReturn(params={"weekday": weekday}, unconsumed=val[match.end():])
-        return None
-
-
-class Locale(Matcher):
-    name: str
-    timedelta: Matcher
-    date: Matcher
-    weekday: Matcher
-    time: Matcher
-
-    def __init__(self, name: str, timedelta: Matcher, date: Matcher, weekday: Matcher,
-                 time: Matcher) -> None:
-        self.name = name
-        self.timedelta = timedelta
-        self.date = date
-        self.weekday = weekday
-        self.time = time
-
-    def replace(self, name: str, timedelta: Matcher = None, date: Matcher = None,
-                weekday: Matcher = None, time: Matcher = None) -> 'Locale':
-        return Locale(name=name, timedelta=timedelta or self.timedelta, date=date or self.date,
-                      weekday=weekday or self.weekday, time=time or self.time)
-
-    def match(self, val: str) -> Optional[MatcherReturn]:
-        found_delta = self.timedelta.match(val)
-        if found_delta:
-            params, val = found_delta
-        else:
-            params = {}
-            found_day = self.weekday.match(val)
-            if found_day:
-                params, val = found_day
-            else:
-                found_date = self.date.match(val)
-                if found_date:
-                    params, val = found_date
-
-            found_time = self.time.match(val)
-            if found_time:
-                params = {**params, **found_time.params}
-                val = found_time.unconsumed
-        return MatcherReturn(params=params, unconsumed=val) if len(params) > 0 else None
-
-
-Locales = Dict[str, Locale]
diff --git a/reminder/locales.py b/reminder/locales.py
deleted file mode 100644
index 1b289a1078d3aa42c3cb0b739267b7ab4f0298c6..0000000000000000000000000000000000000000
--- a/reminder/locales.py
+++ /dev/null
@@ -1,141 +0,0 @@
-# reminder - A maubot plugin to remind you about things.
-# Copyright (C) 2020 Tulir Asokan
-#
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-#
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from dateutil.relativedelta import MO, TU, WE, TH, FR, SA, SU
-
-from .locale_util import (Locales, Locale, RegexMatcher,
-                          WeekdayMatcher, TimeMatcher, ShortYearMatcher)
-
-locales: Locales = {}
-
-td_sep_en = r"(?:[\s,]{1,3}(?:and\s)?)?"
-number = r"[+-]?\d+(?:[.,]\d+)?"
-locales["en_iso"] = Locale(
-    name="English (ISO)",
-    timedelta=RegexMatcher(r"(?:(?:in|after)\s)?"
-                           rf"(?:(?P<years>{number})\s?y(?:r|ear)?s?{td_sep_en})?"
-                           rf"(?:(?P<months>{number})\s?mo(?:nth)?s?{td_sep_en})?"
-                           rf"(?:(?P<weeks>{number})\s?w(?:k|eek)?s?{td_sep_en})?"
-                           rf"(?:(?P<days>{number})\s?d(?:ays?)?{td_sep_en})?"
-                           rf"(?:(?P<hours>{number})\s?h(?:(?:r|our)?s?){td_sep_en})?"
-                           rf"(?:(?P<minutes>{number})\s?m(?:in(?:ute)?s?)?{td_sep_en})?"
-                           rf"(?:(?P<seconds>{number})\s?s(?:ec(?:ond)?s?)?)?"
-                           r"(?:\s|$)"),
-    date=RegexMatcher(r"(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})\s"),
-    weekday=WeekdayMatcher(pattern=r"(?:today"
-                                   r"|tomorrow"
-                                   r"|mon(?:day)?"
-                                   r"|tues?(?:day)?"
-                                   r"|wed(?:nesday)?"
-                                   r"|thu(?:rs(?:day)?)?"
-                                   r"|fri(?:day)?"
-                                   r"|sat(?:urday)?"
-                                   r"|sun(?:day)?)"
-                                   r"(?:\s|$)",
-                           map={
-                               "tod": +0, "tom": +1, "mon": MO, "tue": TU, "wed": WE, "thu": TH,
-                               "fri": FR, "sat": SA, "sun": SU,
-                           }, substr=3),
-    time=RegexMatcher(r"\s?(?:at\s)?"
-                      r"(?P<hour>\d{2})"
-                      r"[:.](?P<minute>\d{2})"
-                      r"(?:[:.](?P<second>\d{2}))?"
-                      r"(?:\s|$)"),
-)
-
-time_12_en = TimeMatcher(r"\s?(?:at\s)?"
-                         r"(?P<hour>\d{2})"
-                         r"(?:[:.](?P<minute>\d{2}))?"
-                         r"(?:[:.](?P<second>\d{2}))?"
-                         r"(?:\s(?P<meridiem>a\.?m|p\.?m)\.?)?"
-                         r"(?:\s|$)")
-
-locales["en_us"] = locales["en_iso"].replace(
-    name="English (US)", time=time_12_en, date=ShortYearMatcher(
-        r"(?P<month>\d{1,2})/(?P<day>\d{1,2})(?:/(?P<year>\d{2}(?:\d{2})?))?(?:\s|$)"))
-
-locales["en_uk"] = locales["en_iso"].replace(
-    name="English (UK)", time=time_12_en, date=ShortYearMatcher(
-        r"(?P<day>\d{1,2})/(?P<month>\d{1,2})(?:/(?P<year>\d{2}(?:\d{2})?))?(?:\s|$)"))
-
-td_sep_fi = r"(?:[\s,]{1,3}(?:ja\s)?)?"
-locales["fi_fi"] = Locale(
-    name="Finnish",
-    timedelta=RegexMatcher(rf"(?:(?P<years>{number})\s?v(?:uo(?:tta|den))?{td_sep_fi})?"
-                           rf"(?:(?P<months>{number})\s?k(?:k|uukau(?:si|tta|den))?{td_sep_fi})?"
-                           rf"(?:(?P<weeks>{number})\s?v(?:k|iikk?o[an]?){td_sep_fi})?"
-                           rf"(?:(?P<days>{number})\s?p(?:v|äivä[än]?){td_sep_fi})?"
-                           rf"(?:(?P<hours>{number})\s?t(?:un(?:nin?|tia))?{td_sep_fi})?"
-                           rf"(?:(?P<minutes>{number})\s?m(?:in(?:uut(?:in?|tia))?)?{td_sep_fi})?"
-                           rf"(?:(?P<seconds>{number})\s?s(?:ek(?:un(?:nin?|tia))?)?)?"
-                           r"(?:\s(?:kuluttua|päästä?))?"
-                           r"(?:\s|$)"),
-    date=ShortYearMatcher(r"(?P<day>\d{1,2})\.(?P<month>\d{1,2})\.(?P<year>\d{2}(?:\d{2})?)\s"),
-    weekday=WeekdayMatcher(pattern=r"(?:tänään"
-                                   r"|(?:yli)?huomen"
-                                   r"|ma(?:aanantai)?"
-                                   r"|ti(?:iistai)?"
-                                   r"|ke(?:skiviikko)?"
-                                   r"|to(?:rstai)?"
-                                   r"|pe(?:rjantai)?"
-                                   r"|la(?:uantai)?"
-                                   r"|su(?:nnuntai)?)"
-                                   r"(?:na)?"
-                                   r"(?:\s|$)",
-                           map={
-                               "tä": +0, "hu": +1, "yl": +2,
-                               "ma": MO, "ti": TU, "ke": WE, "to": TH, "pe": FR, "la": SA, "su": SU,
-                           }, substr=2),
-    time=RegexMatcher(r"\s?(?:ke?ll?o\.?\s)?"
-                      r"(?P<hour>\d{1,2})"
-                      r"[:.](?P<minute>\d{2})"
-                      r"(?:[:.](?P<second>\d{2}))?"
-                      r"(?:\s|$)"),
-)
-
-td_sep_de = r"(?:[\s,]{1,3}(?:und\s)?)?"
-locales["de_de"] = Locale(
-    name="German",
-    timedelta=RegexMatcher(rf"(?:in\s)?"
-                           rf"(?:(?P<years>{number})\s?jahr(?:en)?{td_sep_de})?"
-                           rf"(?:(?P<months>{number})\s?monat(?:en)?{td_sep_de})?"
-                           rf"(?:(?P<weeks>{number})\s?wochen?{td_sep_de})?"
-                           rf"(?:(?P<days>{number})\s?tag(?:en)?{td_sep_de})?"
-                           rf"(?:(?P<hours>{number})\s?stunden?{td_sep_de})?"
-                           rf"(?:(?P<minutes>{number})\s?minuten?{td_sep_de})?"
-                           rf"(?:(?P<seconds>{number})\s?sekunden?)?"
-                           r"(?:\s|$)"),
-    date=ShortYearMatcher(
-        r"(?P<day>\d{1,2})\.(?P<month>\d{1,2})\.(?P<year>\d{2}(?:\d{2})?)(?:\s|$)"),
-    weekday=WeekdayMatcher(pattern=r"(?:heute"
-                                   r"|(?:über)?morgen"
-                                   r"|mo(?:ntag)?"
-                                   r"|di(?:enstag)?"
-                                   r"|mi(?:ttwoch)?"
-                                   r"|do(?:nnerstag)?"
-                                   r"|fr(?:eitag)?"
-                                   r"|sa(?:mstag)?"
-                                   r"|so(?:nntag)?)"
-                                   r"(?:\s|$)",
-                           map={
-                               "heu": +0, "mor": +1, "übe": +2, "mon": MO, "die": TU, "mit": WE,
-                               "don": TH, "fre": FR, "sam": SA, "son": SU,
-                           }, substr=3),
-    time=RegexMatcher(r"\s?(?:um\s)?"
-                      r"(?P<hour>\d{1,2})"
-                      r"[:.](?P<minute>\d{2})"
-                      r"(?:[:.](?P<second>\d{2}))?"
-                      r"(?:\s|$)"),
-)
diff --git a/reminder/migrations.py b/reminder/migrations.py
new file mode 100644
index 0000000000000000000000000000000000000000..276e7fc6026e186d5e085eccecaef9b830413287
--- /dev/null
+++ b/reminder/migrations.py
@@ -0,0 +1,57 @@
+# reminder - A maubot plugin to remind you about things.
+# Copyright (C) 2020 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from mautrix.util.async_db import Connection, Scheme, UpgradeTable
+
+upgrade_table = UpgradeTable()
+
+
+@upgrade_table.register(description="Latest revision")
+async def upgrade_v1(conn: Connection, scheme: Scheme) -> None:
+    await conn.execute(
+        f"""CREATE TABLE IF NOT EXISTS reminder (
+            event_id    VARCHAR(255) NOT NULL,  /* event_id of the message that created the reminder */
+            room_id     VARCHAR(255) NOT NULL,  /* room_id for this reminder */
+            start_time  TEXT,                   /* time for one-off reminders, stored as an ISO 8601 string because sqlite doesn't have date types */
+            message     TEXT,                   /* message for the reminder */
+            reply_to    VARCHAR(255),           /* if the reminder created as a reply to another message, this is that message's event_id */
+            cron_tab    TEXT,                   /* cron string if it's a cron reminder */
+            creator     VARCHAR(255),           /* user_id of the person who created the reminder */
+            recur_every TEXT,                   /* string to parse to schedule the next recurring reminder, e.g. 'tuesday at 2pm' */
+            is_agenda   BOOL,                   /* agendas are alarms that don't trigger */
+            confirmation_event TEXT,            /* event_id of the confirmation message, so that we can delete the confirmation if the reminder is deleted */
+            PRIMARY KEY (event_id)
+            
+        )"""
+    )
+
+    await conn.execute(
+        f"""CREATE TABLE IF NOT EXISTS reminder_target (
+            event_id            VARCHAR(255) NOT NULL,  /* event_id in the reminder table */
+            user_id             VARCHAR(255) NOT NULL,  /* user_id of the subscriber */
+            subscribing_event   VARCHAR(255) NOT NULL,  /* event_id of the event creating the subscription, either a 👍 or the reminder message itself */
+            PRIMARY KEY (user_id, event_id),
+            FOREIGN KEY (event_id) REFERENCES reminder (event_id) ON DELETE CASCADE
+        )"""
+    )
+
+    await conn.execute(
+        f"""CREATE TABLE IF NOT EXISTS user_settings (
+            user_id     VARCHAR(255) NOT NULL,  /* user_id */
+            timezone    TEXT,                   /* user's timezone, e.g. America/Los_Angeles, PST. see https://en.wikipedia.org/wiki/List_of_tz_database_time_zones */
+            locale      TEXT,                   /* user's locale or langauge, e.g. en, en-AU, fr, fr-CA. See https://dateparser.readthedocs.io/en/latest/supported_locales.html */
+            PRIMARY KEY (user_id)
+        )"""
+    )
diff --git a/reminder/reminder.py b/reminder/reminder.py
new file mode 100644
index 0000000000000000000000000000000000000000..3ff3c29b13563756d0bfc74eba27f304608a3ce7
--- /dev/null
+++ b/reminder/reminder.py
@@ -0,0 +1,218 @@
+from __future__ import annotations
+import logging
+from datetime import datetime
+from typing import Dict, Optional, TYPE_CHECKING
+
+import pytz
+from apscheduler.triggers.cron import CronTrigger
+from apscheduler.triggers.date import DateTrigger
+
+from mautrix.util import markdown
+
+from mautrix.types import (Format, MessageType, TextMessageEventContent, UserID, EventID, RoomID)
+
+from .util import CommandSyntaxError, CommandSyntax, make_pill, parse_date, UserInfo, format_time
+
+try:
+    from cron_descriptor import Options, CasingTypeEnum, DescriptionTypeEnum, ExpressionDescriptor
+    USE_CRON_DESCRIPTOR = True
+except ImportError:
+    USE_CRON_DESCRIPTOR = False
+
+if TYPE_CHECKING:
+    from .bot import ReminderBot
+
+logger = logging.getLogger(__name__)
+
+class Reminder(object):
+    def __init__(
+        self,
+        bot: ReminderBot,
+        room_id: RoomID,
+        message: str,
+        event_id: Optional[EventID] = None,
+        reply_to: Optional[EventID] = None,
+        start_time: Optional[datetime] = None,
+        recur_every: Optional[str] = None,
+        cron_tab: Optional[str] = None,
+        subscribed_users: Optional[Dict[EventID, UserID]] = None,
+        creator: Optional[UserID] = None,
+        user_info: Optional[UserInfo] = None,
+        confirmation_event: EventID = None,
+        is_agenda: bool = False,
+    ):
+        """An object containing information about a reminder, when it should go off,
+        whether it is recurring, etc.
+
+        Args:
+            bot: instance of the main bot (this feels wrong but it works)
+            room_id: The ID of the room the reminder should appear in
+            message: The text to include in the reminder message
+            event_id: The event ID of the message creating the reminder
+            reply_to: The event ID of the message the reminder is replying to if applicable, so we can link it in the reply
+            start_time: When the reminder should first go off
+            recur_every: date string to parse to schedule the next recurring reminder
+            cron_tab: cron text
+            subscribed_users: dict of subscribed users with corresponding subscription events
+            creator: ID of the creator
+            user_info: contains timezone and locale for the person who created the reminder
+            confirmation_event: EventID of the confirmation message. Store this so it can be redacted when the reminder is removed.
+            is_agenda: Agenda items are reminders that never fire.
+        """
+        self.bot = bot
+        self.room_id = room_id
+        self.message = message
+        self.event_id = event_id
+        self.reply_to = reply_to
+        self.start_time = start_time
+        self.recur_every = recur_every
+        self.cron_tab = cron_tab
+        self.creator = creator
+        self.subscribed_users = subscribed_users if subscribed_users else {}
+        self.confirmation_event = confirmation_event
+        self.is_agenda = is_agenda
+
+        # Schedule the reminder
+
+        # TODO add agenda
+        trigger = None
+        self.job = None
+
+        if not is_agenda:
+            # Determine how the reminder is triggered.
+            # For both once-off and recurring reminders, user date trigger (runs once).
+            # Recurring reminders are rescheduled when the job runs, so we don't need to worry about them here.
+            if cron_tab:
+                try:
+                    self.cron_tab = cron_tab.removeprefix("cron")
+                    trigger = CronTrigger.from_crontab(self.cron_tab, timezone=user_info.timezone)
+                except ValueError as e:
+                    raise CommandSyntaxError(f"The crontab `{self.cron_tab}` is invalid. \n\n\t{str(e)}",
+                                             CommandSyntax.CRON_EXAMPLE)
+
+            elif recur_every and start_time < datetime.now(tz=pytz.UTC):
+                # If a recurring reminder's last fire was missed, this should fix it
+                start_time, _ = parse_date(self.recur_every, user_info=user_info)
+                trigger = DateTrigger(run_date=start_time, timezone=user_info.timezone)
+
+            elif start_time:
+                trigger = DateTrigger(run_date=start_time, timezone=user_info.timezone)
+
+            # Note down the job for later manipulation
+            self.job = self.bot.scheduler.add_job(self._fire, trigger=trigger, id=self.event_id)
+
+    async def _fire(self):
+        """Called when a reminder fires"""
+        logger.debug("Reminder in room %s fired: %s", self.room_id, self.message)
+
+        user_info = await self.bot.db.get_user_info(self.creator)
+
+        # If this is a recurring message, parse the date again and reschedule the reminder
+        if self.recur_every:
+            start_time, _ = parse_date(self.recur_every, user_info=user_info)
+            trigger = DateTrigger(run_date=start_time, timezone=user_info.timezone)
+            self.job = self.bot.scheduler.add_job(self._fire, trigger=trigger, id=self.event_id)
+            await self.bot.db.reschedule_reminder(start_time=start_time, event_id=self.event_id)
+
+
+        # Check if the user is rate limited
+        reminder_count = user_info.check_rate_limit(max_calls=self.bot.config["rate_limit"],
+                                                    time_window=self.bot.config["rate_limit_minutes"])
+
+
+        # Send the message to the room if we aren't rate limited
+        if reminder_count > self.bot.config["rate_limit"]:
+            logger.debug(f"User {self.creator} is rate limited skipping reminder: {self.message}")
+        else:
+            # Build the message with the format "(users to ping) ⬆️(link to the reminder): message text [next run]
+            targets = list(self.subscribed_users.values())
+            link = f"https://matrix.to/#/{self.room_id}/{self.event_id}"
+            users = " ".join([(await make_pill(user_id=user_id, client=self.bot.client))
+                                  for user_id in targets])
+            body = f"{users}: [⬆]({link}) {self.message}"
+            if self.recur_every or self.cron_tab:
+                body += f"\n\nReminding again {self.formatted_time(user_info)}"
+            # Warn the user before rate limiting happens
+            if reminder_count == self.bot.config["rate_limit"]:
+                body += f"\n\n*You've reached the rate limit " \
+                        f"({self.bot.config['rate_limit']} per {self.bot.config['rate_limit_minutes']} minutes). " \
+                        f"Any upcoming reminders might be ignored!*"
+
+            # Create the message, and include all the data necessary to reschedule the reminder in org.bytemarx.reminder
+            content = TextMessageEventContent(
+                msgtype=MessageType.TEXT, body=body, format=Format.HTML, formatted_body=markdown.render(body))
+            content["org.bytemarx.reminder"] = {"id": self.event_id,
+                                              "message": self.message,
+                                              "reply_to": self.reply_to}
+
+            # Add subscribed users to MSC3952 mentions
+            content["m.mentions"] = {"room": True} if "@room" in targets else {"user_ids": targets}
+
+            if self.reply_to:
+                content.set_reply(await self.bot.client.get_event(self.room_id, self.reply_to))
+
+            await self.bot.client.send_message(self.room_id, content)
+
+        # If this was a one-time reminder, cancel and remove from the reminders dict
+        if not self.recur_every and not self.cron_tab:
+            # We set cancel_alarm to False here else the associated alarms wouldn't even fire
+            await self.cancel(redact_confirmation=False)
+
+
+    async def cancel(self, redact_confirmation: bool = False):
+        """Cancels a reminder and all recurring instances
+
+        Args:
+            redact_confirmation: Whether to also redact the confirmation message
+        """
+        logger.debug(f"Removing reminder in room {self.room_id}: {self.message}")
+        # Delete the reminder from the database
+        await self.bot.db.delete_reminder(self.event_id)
+        # Delete any ongoing jobs
+        if self.job and self.bot.scheduler.get_job(self.job.id):
+            self.job.remove()
+        if redact_confirmation and self.confirmation_event:
+            await self.bot.client.redact(self.room_id, self.confirmation_event)
+        self.bot.reminders.pop(self.event_id)
+
+    async def add_subscriber(self, user_id: UserID, subscribing_event: EventID):
+        if user_id not in self.subscribed_users.values():
+            self.subscribed_users[subscribing_event] = user_id
+            await self.bot.db.add_subscriber(reminder_id=self.event_id,
+                                             user_id=user_id,
+                                             subscribing_event=subscribing_event)
+
+    async def remove_subscriber(self, subscribing_event: EventID):
+        if subscribing_event in self.subscribed_users:
+            await self.bot.db.remove_subscriber(subscribing_event=subscribing_event)
+            del self.subscribed_users[subscribing_event]
+
+
+    def formatted_time(self, user_info: UserInfo):
+        """
+        Format the next run time. as
+        Cron reminders are formatted using cron_descriptor,
+        Args:
+            user_info:
+        Returns:
+
+        """
+        if self.is_agenda:
+            return format_time(self.start_time, user_info)
+        else:
+            next_run = format_time(self.job.next_run_time, user_info)
+            if self.cron_tab:
+                # TODO add languages
+                if USE_CRON_DESCRIPTOR:
+                    return f"{ExpressionDescriptor(self.cron_tab, casing_type=CasingTypeEnum.LowerCase)} (`{self.cron_tab}`), next run {next_run}"
+                else:
+                    return f"`{self.cron_tab}`, next run {next_run}"
+            elif self.recur_every:
+                return f"every {self.recur_every}, next run {next_run}"
+            else: # once-off reminders
+                return next_run
+
+    async def set_confirmation(self, confirmation_event: EventID):
+        """ Set the confirmation message so that it can be redacted if the message is deleted"""
+        self.confirmation_event = confirmation_event
+        await self.bot.db.set_confirmation_event(event_id=self.event_id, confirmation_event=confirmation_event)
diff --git a/reminder/util.py b/reminder/util.py
index a87b96e9ff01a6605b31e8b051e2c053912e6d6c..26ad73c071fe2a31c400c156a46455f4254ef830 100644
--- a/reminder/util.py
+++ b/reminder/util.py
@@ -13,94 +13,276 @@
 #
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
-from typing import Optional, Dict, List, Union, Tuple, TYPE_CHECKING
+from __future__ import annotations
+from collections import deque
+
+from typing import Optional, Dict, List, Tuple, TYPE_CHECKING
 from datetime import datetime, timedelta
 from attr import dataclass
-import re
-
+from dateparser.search import search_dates
+import dateparser
+import logging
 import pytz
-from dateutil.relativedelta import relativedelta
+from enum import Enum
 
+from maubot.client import MaubotMatrixClient
 from mautrix.types import UserID, RoomID, EventID
-from mautrix.util.config import BaseProxyConfig, ConfigUpdateHelper
-from maubot import MessageEvent
-from maubot.handlers.command import Argument, ArgumentSyntaxError
+from maubot.handlers.command import  ArgumentSyntaxError
 
-from .locales import locales
+try:
+    import arrow
+    USE_ARROW = True
+except ImportError:
+    USE_ARROW = False
 
 if TYPE_CHECKING:
-    from .bot import ReminderBot
+    from .reminder import Reminder
+
+logger = logging.getLogger(__name__)
+
+
+class CommandSyntax(Enum):
+    REMINDER_CREATE = """
+`!{base_command} <message containing date>` Adds a reminder by extracting the date from the text
+* `!{base_command} abolish closed-access journals at 3pm tomorrow`
+* `!{base_command} 8 hours buy more pumpkins`
+* `!{base_command} 2023-11-30 15:00 befriend rats`
+
+`!{base_command} <date>; <message>` Bypasses text parsing by explicitly specifying the date
+* `!{base_command} 2 days 4 hours; do something`
+
+`!{base_command} [room] [every] ...`
+* `[room]` pings the whole room
+* `[every]` create recurring reminders `!{base_command} every friday 3pm take out the trash`
+
+`!{base_command} [room] <cron> <message>` Schedules a reminder using a crontab syntax
+* `!{base_command} cron 30 9 * * mon-fri do something` sets reminders for 9:30am, Monday through Friday.
+* `!{base_command} cron` lists more examples
+
+You can also reply to any message with `!{base_command} ...` to get reminded about that message.\\
+To get pinged by someone else's reminder, react to their message with 👍.
+"""
+
+    AGENDA_CREATE = """
+`!{agenda_command} [room] <message>` creates an agenda item. Agenda items are like reminders but don't have a time, for things like to-do lists.
+    """
 
+    REMINDER_LIST = """
+`!{base_command} list [all] [my] [subscribed]` lists all reminders in a room 
+* `all` lists all reminders from every room
+* `my` lists only reminders you created
+* `subscribed` lists only reminders you are subscribed to
+    """
 
-class Config(BaseProxyConfig):
-    def do_update(self, helper: ConfigUpdateHelper) -> None:
-        helper.copy("default_timezone")
-        helper.copy("base_command")
+    REMINDER_CANCEL = """
+Cancel reminders by removing the message creating it, unsubscribe by removing your upvote.\\
+Cancel recurring reminders by replying to the ping with `!{base_command} cancel|delete` 
+* `!{base_command} cancel|delete <ID>` deletes a reminder matching the 4 letter ID shown by `list`
+* `!{base_command} cancel|delete <message>` deletes a reminder *beginning with* <message>
+    * e.g. `!remind delete buy more` would delete the reminder `buy more pumpkins`
+"""
 
+    REMINDER_RESCHEDULE = """
+Reminders can be rescheduled after they have fired by replying with `!{base_command} <new date>`
+"""
 
-class DateArgument(Argument):
-    def __init__(self, name: str, label: str = None, *, required: bool = False):
-        super().__init__(name, label=label, required=required, pass_raw=True)
+    REMINDER_SETTINGS = """
+Dates are parsed using your [timezone](https://en.wikipedia.org/wiki/List_of_tz_database_time_zone) and [locale](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
+Defaults are `{default_tz}` and `{default_locale}`
+* `!{base_command} tz|timezone [new-timezone]` view or set your timezone
+* `!{base_command} locale [new-locale]` view or set your locale
+"""
+
+    SEARCH_DATE_EXAMPLES = "Example: `abolish closed-access journals at 11am on wednesday`, `8 hours buy more pumpkins`, `2023-11-30 15:00 befriend rats`"
+    PARSE_DATE_EXAMPLES = "Examples: `Tuesday at noon`, `8 hours`, `2023-11-30 10:15 pm`"
+
+
+    "Cancel a reminder by either redacting the message, using `!cancel <message>`, or replying to a recurring reminder with `!cancel`"
+
+    CRON_EXAMPLE = """
+```
+*	any value
+,	value list separator
+-	range of values
+/	step 
+
+┌─────── minute (0 - 59)
+│ ┌─────── hour (0 - 23)
+│ │ ┌─────── day of the month (1 - 31)
+│ │ │ ┌─────── month (1 - 12)
+│ │ │ │ ┌─────── weekday (0 - 6) (Sunday to Saturday)                             
+│ │ │ │ │
+* * * * * <message>
+```
+
+```
+30 9 * * *              Every day at 9:30am
+0/30 9-17 * * mon-fri   Every 30 minutes from 9am to 5pm, Monday through Friday
+0 14 1,16 * *           2:00pm on the 1st and 16th day of the month
+0 0 1-7 * mon           First Monday of the month at midnight
+```
+ """
+
+
+@dataclass
+class UserInfo:
+    locale: str = None
+    timezone: str = None
+    last_reminders: deque = deque()
 
-    def match(self, val: str, evt: MessageEvent = None, instance: 'ReminderBot' = None
-              ) -> Tuple[str, Optional[datetime]]:
-        tz = pytz.UTC
-        use_locales = [locales["en_iso"]]
-        if instance:
-            tz = instance.db.get_timezone(evt.sender, instance.default_timezone)
-            locale_ids = instance.db.get_locales(evt.sender)
-            use_locales = [locales[id] for id in locale_ids if id in locales]
+    def check_rate_limit(self, max_calls=5, time_window=60) -> int:
+        """ Implement a sliding window rate limit on the number of reminders per user
+        Args:
+            max_calls:
+            time_window: moving window size in minutes
+        Returns:
+            The number of calls within the sliding window
+        """
+        now = datetime.now(pytz.UTC)
+        # remove timestamps outside the sliding window
+        while len(self.last_reminders) and self.last_reminders[0] + timedelta(minutes=time_window) < now:
+            self.last_reminders.popleft()
+        if len(self.last_reminders) < max_calls:
+            self.last_reminders.append(now)
+        return len(self.last_reminders)
 
-        for locale in use_locales:
-            match = locale.match(val)
-            if match:
-                date = (datetime.now(tz=tz) + relativedelta(**match.params)).astimezone(pytz.UTC)
-                return match.unconsumed, date
-        return val, None
+class CommandSyntaxError(ValueError):
+    def __init__(self, message: str, command: CommandSyntax | None = None):
+        """ Format error messages with examples """
+        super().__init__(f"{message}")
 
+        if command:
+            message += "\n\n" + command.value
+        self.message = message
 
-def parse_timezone(val: str) -> Optional[pytz.timezone]:
-    if not val:
-        return None
+def validate_timezone(tz: str) -> bool | str:
     try:
-        return pytz.timezone(val)
-    except pytz.UnknownTimeZoneError as e:
-        raise ArgumentSyntaxError(f"{val} is not a valid time zone.", show_usage=False) from e
+        return dateparser.utils.get_timezone_from_tz_string(tz).tzname(None)
+    except pytz.UnknownTimeZoneError:
+        return False
 
+def validate_locale(locale: str):
+    try:
+        return dateparser.languages.loader.LocaleDataLoader().get_locale(locale)
+    except ValueError:
+        return False
+
+def parse_date(str_with_time: str, user_info: UserInfo, search_text: bool=False) -> Tuple[datetime, str]:
+    """
+    Extract the date from a string.
+
+    Args:
+        str_with_time: A natural language string containing a date.
+        user_info: contains locale and timezone to search within.
+        search_text:
+            if True, search for the date in str_with_time e.g. "Make tea in 4 hours".
+            if False, expect no other text within str_with_time.
+
+    Returns:
+        date (datetime): The datetime of the parsed date.
+        date_str (str): The string containing just the parsed date,
+                e.g. "4 hours" for str_with_time="Make tea in 4 hours".
+    """
+
+    # Until dateparser makes it so locales can be used in the searcher, use this to get date order
+    date_order = validate_locale(user_info.locale).info["date_order"]
+
+    settings = {'TIMEZONE': user_info.timezone,
+                'TO_TIMEZONE': 'UTC',
+                'DATE_ORDER': date_order,
+                'PREFER_DATES_FROM': 'future',
+                'RETURN_AS_TIMEZONE_AWARE': True}
+
+    if search_text:
+        results = search_dates(str_with_time, languages=[user_info.locale.split('-')[0]], settings=settings)
+        if not results:
+            raise CommandSyntaxError("Unable to extract date from string", CommandSyntax.SEARCH_DATE_EXAMPLES)
+        date_str, date = results[0]
+    else:
+        date = dateparser.parse(str_with_time, locales=[user_info.locale], settings=settings)
+        date_str = str_with_time
+        if not date:
+            raise CommandSyntaxError(f"The given time `{str_with_time}` is invalid.", CommandSyntax.PARSE_DATE_EXAMPLES)
+
+    # Round datetime object to the nearest second for nicer display
+    date = date.replace(microsecond=0)
+
+    # Disallow times in the past
+    if date < datetime.now(tz=pytz.UTC):
+        raise CommandSyntaxError(f"Sorry, `{date}` is in the past and I don't have a time machine (yet...)")
+
+    return date, date_str
 
 def pluralize(val: int, unit: str) -> str:
     if val == 1:
         return f"{val} {unit}"
     return f"{val} {unit}s"
 
+def format_time(time: datetime, user_info: UserInfo) -> str:
+    """
+    Format time as something readable by humans. If arrow is installed use it.
+    Args:
+        time: datetime to format
+        user_info: contains locale (if using arrow) and timezone
+
+    Returns:
 
-def format_time(time: datetime) -> str:
+    """
     now = datetime.now(tz=pytz.UTC).replace(microsecond=0)
-    if time - now <= timedelta(days=7):
-        delta = time - now
-        parts = []
-        if delta.days > 0:
-            parts.append(pluralize(delta.days, "day"))
-        hours, seconds = divmod(delta.seconds, 60)
-        hours, minutes = divmod(hours, 60)
-        if hours > 0:
-            parts.append(pluralize(hours, "hour"))
-        if minutes > 0:
-            parts.append(pluralize(minutes, "minute"))
-        if seconds > 0:
-            parts.append(pluralize(seconds, "second"))
-        if len(parts) == 1:
-            return "in " + parts[0]
-        return "in " + ", ".join(parts[:-1]) + f" and {parts[-1]}"
-    return time.strftime("at %H:%M:%S %Z on %A, %B %-d %Y")
+    delta = time - now
 
+    # If the date is coming up in less than a week, print the duration instead of the date
+    if delta <= timedelta(days=7):
+        if USE_ARROW:
+            # Try using the locale with arrow, which isn't guaranteed to be valid because dateparser has more locales
+            try:
+                formatted_time = arrow.get(time).humanize(locale=user_info.locale.split('-')[0])
+            except ValueError:
+                formatted_time = arrow.get(time).humanize()
+        else:
+            parts = []
+
+            relativedelta(now, time)
+            if delta.days > 0:
+                parts.append(pluralize(delta.days, "day"))
+            hours, seconds = divmod(delta.seconds, 60)
+            hours, minutes = divmod(hours, 60)
+            if hours > 0:
+                parts.append(pluralize(hours, "hour"))
+            if minutes > 0:
+                parts.append(pluralize(minutes, "minute"))
+            if seconds > 0:
+                parts.append(pluralize(seconds, "second"))
+            if len(parts) == 1:
+                return "in " + parts[0]
+            return "in " + ", ".join(parts[:-1]) + f" and {parts[-1]}"
+    else:
+        formatted_time = time.astimezone(
+            dateparser.utils.get_timezone_from_tz_string(user_info.timezone)).strftime(
+            "at %I:%M%P %Z on %A, %B %d %Y")
+    return formatted_time
+
+
+async def make_pill(user_id: UserID, display_name: str = None, client: MaubotMatrixClient | None = None) -> str:
+    """Convert a user ID (and optionally a display name) to a formatted user 'pill'
+
+    Args:
+        user_id: The MXID of the user.
+        display_name: An optional display name. Clients like Element will figure out the
+            correct display name no matter what, but other clients may not.
+        client: mautrix client so get the display name.
+    Returns:
+        The formatted user pill.
+    """
+    # Use the user ID as the display_name if not provided
+    if client and not display_name:
+        if user_id == "@room":
+            return '@room'
+        else:
+            display_name = await client.get_displayname(user_id)
+
+    display_name = display_name or user_id
+
+    # return f'<a href="https://matrix.to/#/{user_id}">{display_name}</a>'
+    return f'[{display_name}](https://matrix.to/#/{user_id})'
 
-@dataclass
-class ReminderInfo:
-    id: int = None
-    date: datetime = None
-    room_id: RoomID = None
-    event_id: EventID = None
-    message: str = None
-    reply_to: EventID = None
-    users: Union[Dict[UserID, EventID], List[UserID]] = None
diff --git a/screenshot.png b/screenshot.png
new file mode 100644
index 0000000000000000000000000000000000000000..844341b41c443dcc6b2cf441e20c5fb18c73836e
Binary files /dev/null and b/screenshot.png differ