Files
elm/lessons/07-final-project.md
Mark Gerrard 7d7986f3ab Initial commit: Elm tutorial for JavaScript developers
This tutorial includes:
- 7 progressive lessons covering Elm fundamentals
- Exercises with starter code
- Solutions for exercises
- Final project: Task Manager app with localStorage persistence

Topics covered:
- Basic syntax and types
- Functions and functional programming concepts
- The Elm Architecture (Model-View-Update)
- Lists and Maybe types
- HTTP requests and JSON decoding
- Ports for JavaScript interop
2026-03-11 11:07:15 +00:00

19 KiB

Lesson 7: Final Project - Building a Complete App

Learning Goals

In this final lesson, you will:

  • Apply everything you've learned
  • Build a complete, functional application
  • Understand how to structure larger Elm projects
  • Learn about ports for JavaScript interop

Project: Task Manager

We'll build a task manager with:

  • Add, complete, and delete tasks
  • Filter tasks (all, active, completed)
  • Persist tasks to localStorage (using ports)
  • Clean, modular code structure

Setting Up

mkdir task-manager
cd task-manager
elm init

The Complete Application

Create src/Main.elm:

port module Main exposing (main)

import Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Json.Decode as Decode exposing (Decoder)
import Json.Encode as Encode



-- PORTS


port saveTasks : Encode.Value -> Cmd msg


port loadTasks : (Decode.Value -> msg) -> Sub msg



-- MODEL


type alias Task =
    { id : Int
    , description : String
    , completed : Bool
    }


type Filter
    = All
    | Active
    | Completed


type alias Model =
    { tasks : List Task
    , newTask : String
    , nextId : Int
    , filter : Filter
    }


init : Decode.Value -> ( Model, Cmd Msg )
init flags =
    let
        loadedTasks =
            case Decode.decodeValue tasksDecoder flags of
                Ok tasks ->
                    tasks

                Err _ ->
                    []

        nextId =
            case List.maximum (List.map .id loadedTasks) of
                Just maxId ->
                    maxId + 1

                Nothing ->
                    1
    in
    ( { tasks = loadedTasks
      , newTask = ""
      , nextId = nextId
      , filter = All
      }
    , Cmd.none
    )



-- DECODERS & ENCODERS


taskDecoder : Decoder Task
taskDecoder =
    Decode.map3 Task
        (Decode.field "id" Decode.int)
        (Decode.field "description" Decode.string)
        (Decode.field "completed" Decode.bool)


tasksDecoder : Decoder (List Task)
tasksDecoder =
    Decode.list taskDecoder


encodeTask : Task -> Encode.Value
encodeTask task =
    Encode.object
        [ ( "id", Encode.int task.id )
        , ( "description", Encode.string task.description )
        , ( "completed", Encode.bool task.completed )
        ]


encodeTasks : List Task -> Encode.Value
encodeTasks tasks =
    Encode.list encodeTask tasks



-- UPDATE


type Msg
    = UpdateNewTask String
    | AddTask
    | ToggleTask Int
    | DeleteTask Int
    | SetFilter Filter
    | ClearCompleted
    | LoadedTasks Decode.Value


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        UpdateNewTask text ->
            ( { model | newTask = text }, Cmd.none )

        AddTask ->
            if String.isEmpty (String.trim model.newTask) then
                ( model, Cmd.none )

            else
                let
                    newTask =
                        { id = model.nextId
                        , description = String.trim model.newTask
                        , completed = False
                        }

                    newTasks =
                        model.tasks ++ [ newTask ]
                in
                ( { model
                    | tasks = newTasks
                    , newTask = ""
                    , nextId = model.nextId + 1
                  }
                , saveTasks (encodeTasks newTasks)
                )

        ToggleTask id ->
            let
                toggleTask task =
                    if task.id == id then
                        { task | completed = not task.completed }

                    else
                        task

                newTasks =
                    List.map toggleTask model.tasks
            in
            ( { model | tasks = newTasks }
            , saveTasks (encodeTasks newTasks)
            )

        DeleteTask id ->
            let
                newTasks =
                    List.filter (\task -> task.id /= id) model.tasks
            in
            ( { model | tasks = newTasks }
            , saveTasks (encodeTasks newTasks)
            )

        SetFilter filter ->
            ( { model | filter = filter }, Cmd.none )

        ClearCompleted ->
            let
                newTasks =
                    List.filter (\task -> not task.completed) model.tasks
            in
            ( { model | tasks = newTasks }
            , saveTasks (encodeTasks newTasks)
            )

        LoadedTasks value ->
            case Decode.decodeValue tasksDecoder value of
                Ok tasks ->
                    ( { model | tasks = tasks }, Cmd.none )

                Err _ ->
                    ( model, Cmd.none )



-- VIEW


view : Model -> Html Msg
view model =
    div [ class "container" ]
        [ h1 [] [ text "Task Manager" ]
        , viewInput model.newTask
        , viewFilters model.filter
        , viewTasks model
        , viewFooter model
        ]


viewInput : String -> Html Msg
viewInput newTask =
    div [ class "input-section" ]
        [ input
            [ placeholder "What needs to be done?"
            , value newTask
            , onInput UpdateNewTask
            , onEnter AddTask
            ]
            []
        , button [ onClick AddTask ] [ text "Add" ]
        ]


onEnter : Msg -> Attribute Msg
onEnter msg =
    let
        isEnter code =
            if code == 13 then
                Decode.succeed msg

            else
                Decode.fail "not ENTER"
    in
    on "keydown" (Decode.andThen isEnter (Decode.field "keyCode" Decode.int))


viewFilters : Filter -> Html Msg
viewFilters currentFilter =
    div [ class "filters" ]
        [ filterButton All currentFilter "All"
        , filterButton Active currentFilter "Active"
        , filterButton Completed currentFilter "Completed"
        ]


filterButton : Filter -> Filter -> String -> Html Msg
filterButton filter currentFilter label =
    button
        [ classList [ ( "active", filter == currentFilter ) ]
        , onClick (SetFilter filter)
        ]
        [ text label ]


viewTasks : Model -> Html Msg
viewTasks model =
    let
        filteredTasks =
            filterTasks model.filter model.tasks
    in
    if List.isEmpty model.tasks then
        p [ class "empty-message" ] [ text "No tasks yet. Add one above!" ]

    else if List.isEmpty filteredTasks then
        p [ class "empty-message" ] [ text "No tasks match this filter." ]

    else
        ul [ class "task-list" ]
            (List.map viewTask filteredTasks)


filterTasks : Filter -> List Task -> List Task
filterTasks filter tasks =
    case filter of
        All ->
            tasks

        Active ->
            List.filter (\t -> not t.completed) tasks

        Completed ->
            List.filter .completed tasks


viewTask : Task -> Html Msg
viewTask task =
    li [ classList [ ( "completed", task.completed ) ] ]
        [ input
            [ type_ "checkbox"
            , checked task.completed
            , onClick (ToggleTask task.id)
            ]
            []
        , span [ class "task-description" ] [ text task.description ]
        , button
            [ class "delete-btn"
            , onClick (DeleteTask task.id)
            ]
            [ text "x" ]
        ]


viewFooter : Model -> Html Msg
viewFooter model =
    let
        activeCount =
            List.length (List.filter (\t -> not t.completed) model.tasks)

        completedCount =
            List.length (List.filter .completed model.tasks)

        itemWord =
            if activeCount == 1 then
                "item"

            else
                "items"
    in
    if List.isEmpty model.tasks then
        text ""

    else
        div [ class "footer" ]
            [ span []
                [ text (String.fromInt activeCount ++ " " ++ itemWord ++ " left")
                ]
            , if completedCount > 0 then
                button [ onClick ClearCompleted ]
                    [ text "Clear completed" ]

              else
                text ""
            ]



-- SUBSCRIPTIONS


subscriptions : Model -> Sub Msg
subscriptions _ =
    loadTasks LoadedTasks



-- MAIN


main : Program Decode.Value Model Msg
main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }

The HTML File with JavaScript

Create index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Task Manager</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }

        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            min-height: 100vh;
            padding: 40px 20px;
        }

        .container {
            max-width: 500px;
            margin: 0 auto;
            background: white;
            border-radius: 12px;
            box-shadow: 0 10px 40px rgba(0,0,0,0.2);
            padding: 30px;
        }

        h1 {
            text-align: center;
            color: #333;
            margin-bottom: 30px;
            font-size: 2rem;
        }

        .input-section {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
        }

        .input-section input {
            flex: 1;
            padding: 12px 15px;
            border: 2px solid #e0e0e0;
            border-radius: 8px;
            font-size: 16px;
            transition: border-color 0.2s;
        }

        .input-section input:focus {
            outline: none;
            border-color: #667eea;
        }

        .input-section button {
            padding: 12px 24px;
            background: #667eea;
            color: white;
            border: none;
            border-radius: 8px;
            font-size: 16px;
            cursor: pointer;
            transition: background 0.2s;
        }

        .input-section button:hover {
            background: #5a67d8;
        }

        .filters {
            display: flex;
            gap: 10px;
            margin-bottom: 20px;
            justify-content: center;
        }

        .filters button {
            padding: 8px 16px;
            border: 2px solid #e0e0e0;
            background: white;
            border-radius: 20px;
            cursor: pointer;
            transition: all 0.2s;
        }

        .filters button:hover {
            border-color: #667eea;
        }

        .filters button.active {
            background: #667eea;
            border-color: #667eea;
            color: white;
        }

        .task-list {
            list-style: none;
        }

        .task-list li {
            display: flex;
            align-items: center;
            padding: 15px;
            border-bottom: 1px solid #f0f0f0;
            transition: background 0.2s;
        }

        .task-list li:hover {
            background: #f9f9f9;
        }

        .task-list li.completed .task-description {
            text-decoration: line-through;
            color: #999;
        }

        .task-list input[type="checkbox"] {
            width: 20px;
            height: 20px;
            margin-right: 15px;
            cursor: pointer;
        }

        .task-description {
            flex: 1;
            font-size: 16px;
        }

        .delete-btn {
            background: none;
            border: none;
            color: #ff6b6b;
            font-size: 18px;
            cursor: pointer;
            opacity: 0;
            transition: opacity 0.2s;
        }

        .task-list li:hover .delete-btn {
            opacity: 1;
        }

        .empty-message {
            text-align: center;
            color: #999;
            padding: 40px;
            font-style: italic;
        }

        .footer {
            display: flex;
            justify-content: space-between;
            align-items: center;
            margin-top: 20px;
            padding-top: 15px;
            border-top: 1px solid #f0f0f0;
            color: #666;
            font-size: 14px;
        }

        .footer button {
            background: none;
            border: none;
            color: #999;
            cursor: pointer;
            text-decoration: underline;
        }

        .footer button:hover {
            color: #333;
        }
    </style>
</head>
<body>
    <div id="app"></div>

    <script src="elm.js"></script>
    <script>
        // Load saved tasks from localStorage
        const savedTasks = localStorage.getItem('elm-tasks');
        const initialTasks = savedTasks ? JSON.parse(savedTasks) : [];

        // Initialize Elm app with saved tasks
        const app = Elm.Main.init({
            node: document.getElementById('app'),
            flags: initialTasks
        });

        // Listen for save commands from Elm
        app.ports.saveTasks.subscribe(function(tasks) {
            localStorage.setItem('elm-tasks', JSON.stringify(tasks));
        });
    </script>
</body>
</html>

Building and Running

# Compile Elm to JavaScript
elm make src/Main.elm --output=elm.js

# Open index.html in your browser
# On macOS: open index.html
# On Linux: xdg-open index.html
# On Windows: start index.html

# Or use a local server
python -m http.server 8000
# Then open http://localhost:8000

Understanding Ports

Ports allow Elm to communicate with JavaScript for side effects Elm can't handle natively.

Defining Ports

port module Main exposing (main)

-- Outgoing: Elm -> JavaScript
port saveTasks : Encode.Value -> Cmd msg

-- Incoming: JavaScript -> Elm  
port loadTasks : (Decode.Value -> msg) -> Sub msg

JavaScript Side

// Subscribe to Elm's outgoing port
app.ports.saveTasks.subscribe(function(tasks) {
    localStorage.setItem('elm-tasks', JSON.stringify(tasks));
});

// Send data to Elm's incoming port
app.ports.loadTasks.send(someData);

Flags: Initial Data

-- Elm receives flags in init
init : Decode.Value -> ( Model, Cmd Msg )
init flags =
    -- Decode flags (initial tasks from localStorage)
// JavaScript passes flags at startup
Elm.Main.init({
    node: document.getElementById('app'),
    flags: initialTasks
});

Code Organization Tips

For Larger Apps

src/
├── Main.elm           # Entry point
├── Model.elm          # Model type and init
├── Update.elm         # Update function and messages
├── View.elm           # View functions
├── Types.elm          # Shared types
├── Api.elm            # HTTP requests
└── Ports.elm          # Port declarations

Splitting the Model

-- Types.elm
module Types exposing (..)

type alias Task =
    { id : Int
    , description : String
    , completed : Bool
    }

type Filter
    = All
    | Active
    | Completed

Helper Functions

Extract common patterns:

-- Helpers.elm
module Helpers exposing (..)

updateTask : Int -> (Task -> Task) -> List Task -> List Task
updateTask targetId transform tasks =
    List.map
        (\task ->
            if task.id == targetId then
                transform task
            else
                task
        )
        tasks

Testing Your Elm Code

Install elm-test

npm install -g elm-test
elm-test init

Writing Tests

-- tests/TaskTests.elm
module TaskTests exposing (..)

import Expect
import Test exposing (..)
import Main exposing (filterTasks, Filter(..))


suite : Test
suite =
    describe "Task filtering"
        [ test "All filter shows all tasks" <|
            \_ ->
                let
                    tasks =
                        [ { id = 1, description = "Task 1", completed = False }
                        , { id = 2, description = "Task 2", completed = True }
                        ]
                in
                filterTasks All tasks
                    |> List.length
                    |> Expect.equal 2
        
        , test "Active filter shows only incomplete" <|
            \_ ->
                let
                    tasks =
                        [ { id = 1, description = "Task 1", completed = False }
                        , { id = 2, description = "Task 2", completed = True }
                        ]
                in
                filterTasks Active tasks
                    |> List.length
                    |> Expect.equal 1
        ]

Running Tests

elm-test

Exercises

Exercise 7.1: Add Edit Functionality

Add the ability to edit task descriptions:

  1. Double-click a task to edit it
  2. Press Enter to save
  3. Press Escape to cancel
Hint

Add a new field to the model:

type alias Model =
    { tasks : List Task
    , newTask : String
    , nextId : Int
    , filter : Filter
    , editing : Maybe Int  -- ID of task being edited
    , editText : String
    }

Add new messages:

type Msg
    = ...
    | StartEditing Int String
    | UpdateEditText String
    | SaveEdit
    | CancelEdit

Exercise 7.2: Add Due Dates

Extend tasks to have optional due dates:

  1. Add a date input when creating tasks
  2. Display due dates with tasks
  3. Highlight overdue tasks

Exercise 7.3: Add Categories

Add task categories:

  1. Tasks can be tagged with a category
  2. Filter by category
  3. Add color coding

Key Takeaways from This Tutorial

  1. Elm is purely functional - All functions are pure, side effects are managed
  2. Types are your friend - They catch bugs at compile time
  3. The Elm Architecture - Model, View, Update provides clean structure
  4. Maybe eliminates nulls - No more "undefined is not a function"
  5. JSON decoders ensure type-safe API integration
  6. Ports bridge Elm and JavaScript for browser APIs

Where to Go From Here

Resources

Practice Projects

  1. Weather App - Fetch and display weather data
  2. Markdown Preview - Live markdown editor with preview
  3. Kanban Board - Drag-and-drop task management
  4. Chat Application - WebSocket integration with ports

Advanced Topics to Explore

  • elm-ui - Layout without CSS
  • elm-spa - Single Page Applications
  • GraphQL with elm-graphql
  • Web Components interop

Congratulations!

You've completed this Elm tutorial! You now have:

  • A solid understanding of functional programming concepts
  • Knowledge of Elm's type system
  • Experience with The Elm Architecture
  • Skills to build real-world applications

The best way to learn more is to build something. Pick a project and start coding!


← Previous: Lesson 6 | Back to README