# 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 ```bash mkdir task-manager cd task-manager elm init ``` ## The Complete Application Create `src/Main.elm`: ```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`: ```html