United Kingdom: +44 (0)208 088 8978

A calendar built with F# for your terminal

Dragos takes a look at the "my-calendar" library written in F#

We're hiring Software Developers

Click here to find out more

The project we will highlight today is pretty straightfoward: a calendar application written in F# for your terminal.

Code overview

Let's analyse some of the F# code

open System
open Argu

module Program =

    [<EntryPoint>]
    let main args =
        let errorHandler =
            ProcessExiter(
                colorizer =
                    function
                    | ErrorCode.HelpText -> None
                    | _ -> Some ConsoleColor.Red
            )

        let parser =
            ArgumentParser.Create<Arguments>(programName = "my-calendar", errorHandler = errorHandler)

        let results = parser.ParseCommandLine args
        let args = results.GetAllResults()

        MainHandler.handle args

        0

We can see that we are making use of a parser to handle the commands given and because this is the main entry code we need an exit code. The number "0" indicates a successful process.

Let's look into the handler:


open System
open Spectre.Console
open FsSpectre

[<RequireQualifiedAccess>]
module MainHandler =

    let handle (args: Arguments list) =
        let now = DateTime.Now
        let data = Storage.retrieve ()

        match args with
        | [ Show ] -> Views.mainView now data |> AnsiConsole.Write

        | [ ToDo subArgs ] ->
            let subArgs = subArgs.GetAllResults()
            ToDoHandler.handle now data subArgs

        | [ Event subArgs ] ->
            let subArgs = subArgs.GetAllResults()
            EventHandler.handle now data subArgs

        | [ Recurring_Event subArgs ] ->
            let subArgs = subArgs.GetAllResults()
            RecurringEventHandler.handle now data subArgs

        | _ -> markup { text "Wrong arguments provided!\nTry `my-calendar --help` for more information.\n" } |> AnsiConsole.Write

A straight-forward process that checks the list of arguments given by the user, a unique thing is the markup function coming from FsSpectre that allows us to output a more rich text output in the console.

Coontinuing on that piece of code, let's see what happens when we want to handle a Todo:

open System
open Spectre.Console
open FsSpectre

[<RequireQualifiedAccess>]
module ToDoHandler =

    let handle (now: DateTime) (data: MyCalendarData) (args: ToDoArguments list) =
        match args with
        | [ ToDoArguments.Add ] ->
            let name =
                textPrompt<string> { text "What's the name of the new ToDo?" }
                |> AnsiConsole.Prompt

            let description =
                textPrompt<string> { text "Give it a brief description:" } |> AnsiConsole.Prompt

            let todo =
                { Id = Guid.NewGuid()
                  Name = name
                  Description = description
                  CreatedAt = now
                  MarkedDoneAt = None
                  SoftDeleted = false }

            let newToDos = Array.append [| todo |] data.ToDos
            let newData = { data with ToDos = newToDos }

            Storage.store newData

            Views.mainView now newData |> AnsiConsole.Write

        | [ ToDoArguments.Edit ] ->
            let todo =
                selectionPrompt<ToDo> {
                    title "Select a ToDo you want to edit:"
                    page_size 10
                    choices (ToDo.active data.ToDos)
                }
                |> AnsiConsole.Prompt

            let name =
                textPrompt<string> {
                    text "What's the new name of the ToDo?"
                    default_value todo.Name
                }
                |> AnsiConsole.Prompt

            let description =
                textPrompt<string> {
                    text "What's the new description of the ToDo?"
                    default_value todo.Description
                }
                |> AnsiConsole.Prompt

            let updatedTodo =
                { todo with
                    Name = name
                    Description = description }

            let newToDos = ToDo.update updatedTodo data.ToDos
            let newData = { data with ToDos = newToDos }

            Storage.store newData

            Views.mainView now newData |> AnsiConsole.Write

        | [ ToDoArguments.Done ] ->
            let todo =
                selectionPrompt<ToDo> {
                    title "Select a ToDo you want to mark as done:"
                    page_size 10
                    choices (ToDo.active data.ToDos)
                }
                |> AnsiConsole.Prompt

            let markedDone = { todo with MarkedDoneAt = Some now }
            let newToDos = ToDo.update markedDone data.ToDos
            let newData = { data with ToDos = newToDos }

            Storage.store newData

            Views.mainView now newData |> AnsiConsole.Write

        | [ ToDoArguments.Undone ] ->
            let todo =
                selectionPrompt<ToDo> {
                    title "Select a ToDo you want to undo:"
                    page_size 10
                    choices (ToDo.markedDone data.ToDos)
                }
                |> AnsiConsole.Prompt

            let unmarkedDone = { todo with MarkedDoneAt = None }
            let newToDos = ToDo.update unmarkedDone data.ToDos
            let newData = { data with ToDos = newToDos }

            Storage.store newData

            Views.mainView now newData |> AnsiConsole.Write

        | [ ToDoArguments.Delete ] ->
            let todo =
                selectionPrompt<ToDo> {
                    title "Select a ToDo you want to delete:"
                    page_size 10
                    choices (ToDo.deletable data.ToDos)
                }
                |> AnsiConsole.Prompt

            let deleted = { todo with SoftDeleted = true }
            let newToDos = ToDo.update deleted data.ToDos
            let newData = { data with ToDos = newToDos }

            Storage.store newData

            Views.mainView now newData |> AnsiConsole.Write

        | _ -> markup { text "Wrong sub arguments provided!\nTry `my-calendar todo --help` for more information.\n" } |> AnsiConsole.Write

A similar process, we can notice that when the argument is [ ToDoArguments.Add ] we are storing a record of type Todo using a store function found in the Storage module:

namespace MyCalendar

open System
open System.IO
open FSharp.Json

[<RequireQualifiedAccess>]
module Storage =

    let retrieve () =
        let path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, $"my-calendar-store.json")

        let data =
            if (File.Exists(path)) then
                File.ReadAllText(path) |> Json.deserialize<MyCalendarData>
            else
                MyCalendarData.Default

        data

    let store (data: MyCalendarData) =
        try
            let path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, $"my-calendar-store.json")

            let str = Json.serialize data
            File.WriteAllText(path, str)
        with ex ->
            printfn "%A" (ex.ToString())

This store function stores the data in a .json file

Usage

First, please ensure you are using a Windows-based terminal, something like Powershell and not something like bash, otherwise you will run into issues.

Install with

dotnet tool install --global MyCalendar --version 0.1.1

See how your calendar currently looks by running:

my-calendar show

Right now it's empty, let's add a todo:

my-calendar add todo

This will prompt some questions about the todo before adding to the calendar

We can add events in a similar manner but we will have to provide more details

Check the calendar now:

my-calendar show

We can see that the event and todo were added

Let's remove a todo:

my-calendar todo delete

And we will be able to select the todo we wish to remove

Conclusion

The "my-calendar" library is a highly effective tool if you frequently operate from the terminal and aim to incorporate a calendar into your routine tasks.