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
This commit is contained in:
2026-03-11 11:07:15 +00:00
commit 7d7986f3ab
16 changed files with 5342 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Manager - Elm Tutorial Final Project</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>

View File

@@ -0,0 +1,370 @@
port module Main exposing (main)
{-| Task Manager - Final Project
A complete task manager application demonstrating:
- The Elm Architecture
- Custom types for state management
- Ports for localStorage persistence
- List operations
- JSON encoding/decoding
To run:
1. elm make src/Main.elm --output=elm.js
2. Open index.html in a browser
-}
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
}