├── .prettierrc.json ├── public ├── _redirects ├── robots.txt ├── tick.js ├── bell.wav ├── bong.wav ├── bird-song.wav ├── alarm-clock.wav ├── wind-chimes.wav └── relaxing-percussion.wav ├── .prettierignore ├── favicon.ico ├── icon-192x192.png ├── icon-512x512.png ├── .gitignore ├── apple-icon-180x180.png ├── elm-tooling.json ├── src ├── js │ ├── helpers │ │ ├── flash.ts │ │ ├── alarm-sounds.ts │ │ ├── db.ts │ │ └── local-storage.ts │ ├── monit.ts │ ├── notify.ts │ ├── viewport-fix.ts │ ├── index.ts │ ├── result.ts │ ├── logs.ts │ ├── decoders.ts │ ├── settings.ts │ └── spotify.ts ├── css │ └── style.css ├── env.d.ts ├── Theme │ ├── Common.elm │ ├── Gruvbox.elm │ ├── Tomato.elm │ ├── Nord.elm │ ├── Dracula.elm │ ├── NightMood.elm │ └── Fall.elm ├── Ports.elm ├── Color.elm ├── Misc.elm ├── Spotify.elm ├── globals.d.ts ├── Theme.elm ├── Page │ ├── MiniTimer.elm │ ├── Flash.elm │ ├── Credits.elm │ ├── Spotify.elm │ ├── Settings.elm │ ├── Timer.elm │ └── Stats.elm ├── Settings.elm ├── Elements.elm ├── tsconfig.json ├── Main.elm └── Session.elm ├── elm-watch.json ├── .env.sample ├── .eslintrc.json ├── elm.json ├── vite.config.js ├── index.html ├── package.json ├── review ├── elm.json └── src │ └── ReviewConfig.elm ├── mask-icon.svg ├── icon.svg └── README.md /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | -------------------------------------------------------------------------------- /public/tick.js: -------------------------------------------------------------------------------- 1 | setInterval(() => postMessage(Date.now()), 1000); 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | elm-stuff 3 | dist 4 | dev-dist 5 | public 6 | -------------------------------------------------------------------------------- /favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/favicon.ico -------------------------------------------------------------------------------- /public/bell.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/public/bell.wav -------------------------------------------------------------------------------- /public/bong.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/public/bong.wav -------------------------------------------------------------------------------- /icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/icon-192x192.png -------------------------------------------------------------------------------- /icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/icon-512x512.png -------------------------------------------------------------------------------- /public/bird-song.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/public/bird-song.wav -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | node_modules 3 | elm-stuff 4 | dist 5 | dev-dist 6 | .env 7 | public/main.js 8 | -------------------------------------------------------------------------------- /apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/apple-icon-180x180.png -------------------------------------------------------------------------------- /public/alarm-clock.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/public/alarm-clock.wav -------------------------------------------------------------------------------- /public/wind-chimes.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/public/wind-chimes.wav -------------------------------------------------------------------------------- /public/relaxing-percussion.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eberfreitas/pelmodoro/HEAD/public/relaxing-percussion.wav -------------------------------------------------------------------------------- /elm-tooling.json: -------------------------------------------------------------------------------- 1 | { 2 | "tools": { 3 | "elm": "0.19.1", 4 | "elm-format": "0.8.5", 5 | "elm-json": "0.2.13" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/js/helpers/flash.ts: -------------------------------------------------------------------------------- 1 | import { ElmApp } from "../../globals"; 2 | 3 | export default function (app: ElmApp, msg: unknown): void { 4 | app.ports.gotFlashMsg.send({ msg: msg }); 5 | } 6 | -------------------------------------------------------------------------------- /elm-watch.json: -------------------------------------------------------------------------------- 1 | { 2 | "targets": { 3 | "app": { 4 | "inputs": [ 5 | "src/Main.elm" 6 | ], 7 | "output": "public/main.js" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | VITE_SPOTIFY_CLIENT_ID=xxxx 2 | VITE_SPOTIFY_REDIRECT_URL=https://localhost:1234/settings 3 | VITE_SENTRY_DSN=https://xxxx@xxxx.ingest.sentry.io/xxxx 4 | VITE_SENTRY_SAMPLE_RATE=1.0 5 | VITE_ENVIRONMENT=development 6 | -------------------------------------------------------------------------------- /src/css/style.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | } 6 | 7 | /* CSS hack for full height display: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ */ 8 | 9 | .container { 10 | height: 100vh; /* Fallback for browsers that do not support Custom Properties */ 11 | height: calc(var(--vh, 1vh) * 100); 12 | } 13 | -------------------------------------------------------------------------------- /src/js/monit.ts: -------------------------------------------------------------------------------- 1 | import * as Sentry from "@sentry/browser"; 2 | import { BrowserTracing } from "@sentry/tracing"; 3 | 4 | Sentry.init({ 5 | dsn: import.meta.env.VITE_SENTRY_DSN, 6 | integrations: [new BrowserTracing()], 7 | tracesSampleRate: parseFloat(import.meta.env.VITE_SENTRY_SAMPLE_RATE), 8 | environment: import.meta.env.VITE_ENVIRONMENT, 9 | }); 10 | -------------------------------------------------------------------------------- /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | readonly VITE_SPOTIFY_CLIENT_ID: string; 5 | readonly VITE_SPOTIFY_REDIRECT_URL: string; 6 | readonly VITE_SENTRY_DSN: string; 7 | readonly VITE_SENTRY_SAMPLE_RATE: string; 8 | readonly VITE_ENVIRONMENT: string; 9 | } 10 | 11 | interface ImportMeta extends ImportMeta { 12 | readonly env: ImportMetaEnv; 13 | } 14 | -------------------------------------------------------------------------------- /src/Theme/Common.elm: -------------------------------------------------------------------------------- 1 | module Theme.Common exposing (Theme(..), ThemeColors) 2 | 3 | import Color exposing (Color) 4 | 5 | 6 | type Theme 7 | = Tomato 8 | | NightMood 9 | | Gruvbox 10 | | Dracula 11 | | Nord 12 | | Fall 13 | 14 | 15 | type alias ThemeColors = 16 | { background : Color 17 | , work : Color 18 | , break : Color 19 | , longBreak : Color 20 | , foreground : Color 21 | , contrast : Color 22 | , text : Color 23 | } 24 | -------------------------------------------------------------------------------- /src/js/helpers/alarm-sounds.ts: -------------------------------------------------------------------------------- 1 | import { Howl } from "howler"; 2 | 3 | const alarmSounds: Record = { 4 | "wind-chimes": new Howl({ src: "wind-chimes.wav" }), 5 | bell: new Howl({ src: "bell.wav" }), 6 | "alarm-clock": new Howl({ src: "alarm-clock.wav" }), 7 | bong: new Howl({ src: "bong.wav" }), 8 | "relaxing-percussion": new Howl({ src: "relaxing-percussion.wav" }), 9 | "bird-song": new Howl({ src: "bird-song.wav" }), 10 | } as const; 11 | 12 | export default alarmSounds; 13 | -------------------------------------------------------------------------------- /src/js/notify.ts: -------------------------------------------------------------------------------- 1 | import { ElmApp, NotifyPayload } from "../globals"; 2 | import alarmSounds from "./helpers/alarm-sounds"; 3 | 4 | const notify = (config: NotifyPayload) => { 5 | if (config.config.sound && alarmSounds[config.sound]) { 6 | alarmSounds[config.sound]?.play(); 7 | } 8 | 9 | const permission = Notification.permission; 10 | 11 | if (config.config.browser && permission == "granted") { 12 | return new Notification(config.msg); 13 | } 14 | 15 | return null; 16 | }; 17 | 18 | export default function(app: ElmApp) { 19 | app.ports.notify.subscribe(notify); 20 | } 21 | -------------------------------------------------------------------------------- /src/js/helpers/db.ts: -------------------------------------------------------------------------------- 1 | import Dexie from "dexie"; 2 | import "dexie-export-import"; 3 | 4 | interface Cycles { 5 | id?: number; 6 | start: number | null; 7 | end: number | null; 8 | secs: number | null; 9 | sentiment: string | null; 10 | interval?: { 11 | type: string; 12 | secs: number; 13 | }; 14 | } 15 | 16 | class Database extends Dexie { 17 | cycles!: Dexie.Table; 18 | 19 | constructor() { 20 | super("DB"); 21 | 22 | this.version(1).stores({ 23 | cycles: "++id, start", 24 | }); 25 | } 26 | } 27 | 28 | export default new Database(); 29 | -------------------------------------------------------------------------------- /src/js/helpers/local-storage.ts: -------------------------------------------------------------------------------- 1 | export const get = (key: string, defVal: unknown = null): unknown => { 2 | let parsed: unknown; 3 | 4 | try { 5 | parsed = JSON.parse(window.localStorage.getItem(key) ?? ""); 6 | } catch (_) { 7 | parsed = defVal; 8 | } 9 | 10 | if (parsed == null) { 11 | parsed = defVal; 12 | } 13 | 14 | return parsed; 15 | }; 16 | 17 | export const set = (key: string, data: unknown): void => { 18 | window.localStorage.setItem(key, JSON.stringify(data)); 19 | }; 20 | 21 | export const del = (key: string): void => { 22 | window.localStorage.removeItem(key); 23 | }; 24 | -------------------------------------------------------------------------------- /src/js/viewport-fix.ts: -------------------------------------------------------------------------------- 1 | export default function () { 2 | // Full height display hack: https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ 3 | 4 | // First we get the viewport height and we multiple it by 1% to get a value for a vh unit 5 | let vh = window.innerHeight * 0.01; 6 | 7 | // Then we set the value in the --vh custom property to the root of the document 8 | document.documentElement.style.setProperty("--vh", `${vh}px`); 9 | 10 | // We listen to the resize event 11 | window.addEventListener("resize", () => { 12 | // We execute the same script as before 13 | vh = window.innerHeight * 0.01; 14 | document.documentElement.style.setProperty("--vh", `${vh}px`); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /src/Theme/Gruvbox.elm: -------------------------------------------------------------------------------- 1 | module Theme.Gruvbox exposing (theme) 2 | 3 | import Color 4 | import Theme.Common 5 | 6 | 7 | background : Color.Color 8 | background = 9 | Color.new 40 40 40 1.0 10 | 11 | 12 | grey : Color.Color 13 | grey = 14 | Color.new 146 131 116 1.0 15 | 16 | 17 | green : Color.Color 18 | green = 19 | Color.new 152 151 26 1.0 20 | 21 | 22 | aqua : Color.Color 23 | aqua = 24 | Color.new 104 157 106 1.0 25 | 26 | 27 | beige : Color.Color 28 | beige = 29 | Color.new 235 219 178 1.0 30 | 31 | 32 | red : Color.Color 33 | red = 34 | Color.new 250 72 51 1.0 35 | 36 | 37 | theme : Theme.Common.ThemeColors 38 | theme = 39 | Theme.Common.ThemeColors background green aqua red grey beige grey 40 | -------------------------------------------------------------------------------- /src/Theme/Tomato.elm: -------------------------------------------------------------------------------- 1 | module Theme.Tomato exposing (theme) 2 | 3 | import Color 4 | import Theme.Common 5 | 6 | 7 | tomato : Color.Color 8 | tomato = 9 | Color.new 255 99 71 1.0 10 | 11 | 12 | washedTomato : Color.Color 13 | washedTomato = 14 | Color.new 255 243 240 1.0 15 | 16 | 17 | white : Color.Color 18 | white = 19 | Color.new 255 255 255 1.0 20 | 21 | 22 | darkGrey : Color.Color 23 | darkGrey = 24 | Color.new 34 34 34 1.0 25 | 26 | 27 | purple : Color.Color 28 | purple = 29 | Color.new 130 2 99 1.0 30 | 31 | 32 | green : Color.Color 33 | green = 34 | Color.new 122 199 79 1.0 35 | 36 | 37 | theme : Theme.Common.ThemeColors 38 | theme = 39 | Theme.Common.ThemeColors washedTomato tomato purple green tomato white darkGrey 40 | -------------------------------------------------------------------------------- /src/Theme/Nord.elm: -------------------------------------------------------------------------------- 1 | module Theme.Nord exposing (theme) 2 | 3 | import Color 4 | import Theme.Common 5 | 6 | 7 | darkBlue : Color.Color 8 | darkBlue = 9 | Color.new 59 66 82 1.0 10 | 11 | 12 | blue : Color.Color 13 | blue = 14 | Color.new 136 192 208 1.0 15 | 16 | 17 | blue2 : Color.Color 18 | blue2 = 19 | Color.new 94 129 172 1.0 20 | 21 | 22 | blue3 : Color.Color 23 | blue3 = 24 | Color.new 76 86 106 1.0 25 | 26 | 27 | white : Color.Color 28 | white = 29 | Color.new 236 239 244 1.0 30 | 31 | 32 | snow : Color.Color 33 | snow = 34 | Color.new 216 222 233 1.0 35 | 36 | 37 | red : Color.Color 38 | red = 39 | Color.new 191 97 106 1.0 40 | 41 | 42 | theme : Theme.Common.ThemeColors 43 | theme = 44 | Theme.Common.ThemeColors snow blue blue2 red darkBlue white blue3 45 | -------------------------------------------------------------------------------- /src/Theme/Dracula.elm: -------------------------------------------------------------------------------- 1 | module Theme.Dracula exposing (theme) 2 | 3 | import Color 4 | import Theme.Common 5 | 6 | 7 | background : Color.Color 8 | background = 9 | Color.new 40 42 54 1.0 10 | 11 | 12 | green : Color.Color 13 | green = 14 | Color.new 80 250 123 1.0 15 | 16 | 17 | pink : Color.Color 18 | pink = 19 | Color.new 255 121 198 1.0 20 | 21 | 22 | purple : Color.Color 23 | purple = 24 | Color.new 189 147 249 1.0 25 | 26 | 27 | blue : Color.Color 28 | blue = 29 | Color.new 68 71 90 1.0 30 | 31 | 32 | blue2 : Color.Color 33 | blue2 = 34 | Color.new 98 114 164 1.0 35 | 36 | 37 | white : Color.Color 38 | white = 39 | Color.new 248 248 242 1.0 40 | 41 | 42 | theme : Theme.Common.ThemeColors 43 | theme = 44 | Theme.Common.ThemeColors background pink purple green blue white blue2 45 | -------------------------------------------------------------------------------- /src/Theme/NightMood.elm: -------------------------------------------------------------------------------- 1 | module Theme.NightMood exposing (theme) 2 | 3 | import Color 4 | import Theme.Common 5 | 6 | 7 | darkGrey : Color.Color 8 | darkGrey = 9 | Color.new 34 34 34 1.0 10 | 11 | 12 | darkPink : Color.Color 13 | darkPink = 14 | Color.new 141 48 99 1.0 15 | 16 | 17 | darkPurple : Color.Color 18 | darkPurple = 19 | Color.new 95 46 136 1.0 20 | 21 | 22 | oilBlue : Color.Color 23 | oilBlue = 24 | Color.new 46 117 137 1.0 25 | 26 | 27 | lighterGrey : Color.Color 28 | lighterGrey = 29 | Color.new 90 90 90 1.0 30 | 31 | 32 | dirtyWhite : Color.Color 33 | dirtyWhite = 34 | Color.new 202 202 202 1.0 35 | 36 | 37 | lightGrey : Color.Color 38 | lightGrey = 39 | Color.new 70 70 70 1.0 40 | 41 | 42 | theme : Theme.Common.ThemeColors 43 | theme = 44 | Theme.Common.ThemeColors darkGrey darkPink darkPurple oilBlue lightGrey dirtyWhite lighterGrey 45 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:@typescript-eslint/recommended-requiring-type-checking", 10 | "plugin:@typescript-eslint/strict", 11 | "prettier" 12 | ], 13 | "overrides": [ 14 | ], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaVersion": "latest", 18 | "sourceType": "module", 19 | "project": ["./src/tsconfig.json"] 20 | }, 21 | "plugins": [ 22 | "@typescript-eslint" 23 | ], 24 | "rules": { 25 | "@typescript-eslint/no-explicit-any": "error", 26 | "@typescript-eslint/no-misused-promises": "error", 27 | "@typescript-eslint/no-floating-promises": "error" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Theme/Fall.elm: -------------------------------------------------------------------------------- 1 | module Theme.Fall exposing (theme) 2 | 3 | import Color 4 | import Theme.Common 5 | 6 | -- Orange 7 | work : Color.Color 8 | work = 9 | Color.new 255 145 10 1.0 10 | 11 | -- Dark green 12 | foreground : Color.Color 13 | foreground = 14 | Color.new 69 106 57 1.0 15 | 16 | -- Light peach 17 | background : Color.Color 18 | background = 19 | Color.new 254 244 230 1 20 | 21 | -- White 22 | contrast : Color.Color 23 | contrast = 24 | Color.new 255 255 255 1.0 25 | 26 | -- Blue 27 | text : Color.Color 28 | text = 29 | Color.new 155 106 108 1.0 30 | 31 | -- Pink 32 | break : Color.Color 33 | break = 34 | Color.new 105 162 176 1.0 35 | 36 | -- Bordeaux / brown 37 | longBreak : Color.Color 38 | longBreak = 39 | Color.new 224 82 99 1.0 40 | 41 | 42 | theme : Theme.Common.ThemeColors 43 | theme = 44 | Theme.Common.ThemeColors background work break longBreak foreground contrast text 45 | -------------------------------------------------------------------------------- /src/js/index.ts: -------------------------------------------------------------------------------- 1 | import * as storage from "./helpers/local-storage"; 2 | import viewportFix from "./viewport-fix"; 3 | import notify from "./notify"; 4 | import spotify from "./spotify"; 5 | import logs from "./logs"; 6 | import settings from "./settings"; 7 | import { LocalStoragePayload } from "../globals"; 8 | 9 | const active = storage.get("active", storage.get("current", {})); 10 | const settings_ = storage.get("settings", {}); 11 | 12 | const app = window.Elm.Main.init({ 13 | flags: { 14 | active: active, 15 | settings: settings_, 16 | now: Date.now(), 17 | }, 18 | }); 19 | 20 | app.ports.localStorage.subscribe((payload: LocalStoragePayload) => { 21 | storage.set(payload.key, payload.data); 22 | }); 23 | 24 | viewportFix(); 25 | notify(app); 26 | spotify(app); 27 | logs(app); 28 | settings(app); 29 | 30 | const tickWorker = new Worker("./tick.js"); 31 | 32 | tickWorker.onmessage = ({ data }) => app.ports.tick.send(data); 33 | -------------------------------------------------------------------------------- /src/js/result.ts: -------------------------------------------------------------------------------- 1 | export type Result = 2 | | { status: "ok"; data: T } 3 | | { status: "err"; error: E }; 4 | 5 | export function resultOk(data: T): Result { 6 | return { status: "ok", data }; 7 | } 8 | 9 | export function resultErr(error: E): Result { 10 | return { status: "err", error }; 11 | } 12 | 13 | export function resultMap( 14 | result: Result, 15 | mapFn: (data: T) => N 16 | ): Result { 17 | if (result.status === "ok") { 18 | return resultOk(mapFn(result.data)); 19 | } else { 20 | return result; 21 | } 22 | } 23 | 24 | export function resultCallback( 25 | result: Result, 26 | callback: (data: T) => void | Promise, 27 | errorCallback: ((error: E) => void | Promise) | null = null 28 | ): void { 29 | if (result.status === "err") { 30 | void errorCallback?.(result.error); 31 | } else { 32 | void callback(result.data); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "NoRedInk/elm-json-decode-pipeline": "1.0.0", 10 | "abradley2/elm-calendar": "1.0.0", 11 | "edgerunner/elm-tuple-trio": "1.0.0", 12 | "elm/browser": "1.0.2", 13 | "elm/core": "1.0.5", 14 | "elm/file": "1.0.5", 15 | "elm/json": "1.1.3", 16 | "elm/time": "1.0.0", 17 | "elm/url": "1.0.0", 18 | "elm/virtual-dom": "1.0.2", 19 | "elm-community/list-extra": "8.3.0", 20 | "elm-community/string-extra": "4.0.1", 21 | "justinmimbs/date": "3.2.1", 22 | "rtfeldman/elm-css": "16.1.0", 23 | "rtfeldman/elm-iso8601-date-strings": "1.1.4" 24 | }, 25 | "indirect": { 26 | "elm/bytes": "1.0.8", 27 | "elm/html": "1.0.0", 28 | "elm/parser": "1.1.0", 29 | "elm/regex": "1.0.0", 30 | "rtfeldman/elm-hex": "1.0.0" 31 | } 32 | }, 33 | "test-dependencies": { 34 | "direct": {}, 35 | "indirect": {} 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import { VitePWA } from "vite-plugin-pwa"; 3 | import basicSsl from "@vitejs/plugin-basic-ssl"; 4 | 5 | export default defineConfig({ 6 | server: { 7 | https: true, 8 | }, 9 | plugins: [ 10 | basicSsl(), 11 | VitePWA({ 12 | registerType: "autoUpdate", 13 | devOptions: { 14 | enabled: false, 15 | }, 16 | workbox: { 17 | globPatterns: ["**/*.{html,js,css,png,svg,ico,wav}"], 18 | }, 19 | manifest: { 20 | name: "Pelmodoro", 21 | short_name: "Pelmo", 22 | description: "Simple pomodoro timer with Spotify integration and stats", 23 | theme_color: "#F76045", 24 | icons: [ 25 | { 26 | src: "icon-192x192.png", 27 | sizes: "192x192", 28 | type: "image/png", 29 | }, 30 | { 31 | src: "icon-512x512.png", 32 | sizes: "512x512", 33 | type: "image/png", 34 | }, 35 | { 36 | src: "mask-icon.svg", 37 | sizes: "512x512", 38 | type: "image/svg+xml", 39 | purpose: "maskable", 40 | }, 41 | ], 42 | }, 43 | }), 44 | ], 45 | }); 46 | -------------------------------------------------------------------------------- /src/Ports.elm: -------------------------------------------------------------------------------- 1 | port module Ports exposing 2 | ( gotFlashMsg 3 | , gotFromLog 4 | , gotFromSettings 5 | , gotFromSpotify 6 | , localStorageHelper 7 | , log 8 | , notify 9 | , tick 10 | , toLog 11 | , toSettings 12 | , toSpotify 13 | ) 14 | 15 | import Json.Decode as Decode 16 | import Json.Encode as Encode 17 | 18 | 19 | 20 | -- HELPERS 21 | 22 | 23 | localStorageHelper : String -> Encode.Value -> Cmd msg 24 | localStorageHelper key val = 25 | Encode.object 26 | [ ( "key", Encode.string key ) 27 | , ( "data", val ) 28 | ] 29 | |> localStorage 30 | 31 | 32 | 33 | -- PORTS 34 | 35 | 36 | port localStorage : Encode.Value -> Cmd msg 37 | 38 | 39 | port log : Encode.Value -> Cmd msg 40 | 41 | 42 | port notify : Encode.Value -> Cmd msg 43 | 44 | 45 | port toLog : Encode.Value -> Cmd msg 46 | 47 | 48 | port toSettings : Encode.Value -> Cmd msg 49 | 50 | 51 | port toSpotify : Encode.Value -> Cmd msg 52 | 53 | 54 | 55 | -- SUBS 56 | 57 | 58 | port tick : (Decode.Value -> msg) -> Sub msg 59 | 60 | 61 | port gotFromLog : (Decode.Value -> msg) -> Sub msg 62 | 63 | 64 | port gotFromSettings : (Decode.Value -> msg) -> Sub msg 65 | 66 | 67 | port gotFromSpotify : (Decode.Value -> msg) -> Sub msg 68 | 69 | 70 | port gotFlashMsg : (Decode.Value -> msg) -> Sub msg 71 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Pelmodoro 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "@types/downloadjs": "^1.4.3", 4 | "@types/howler": "^2.2.7", 5 | "@types/spotify-web-playback-sdk": "^0.1.15", 6 | "@typescript-eslint/eslint-plugin": "^5.51.0", 7 | "@typescript-eslint/parser": "^5.51.0", 8 | "@vitejs/plugin-basic-ssl": "^1.0.1", 9 | "elm-review": "^2.5.3", 10 | "elm-tooling": "^1.12.0", 11 | "elm-watch": "^1.1.2", 12 | "eslint": "^8.34.0", 13 | "eslint-config-prettier": "^8.6.0", 14 | "prettier": "2.8.4", 15 | "prettier-plugin-organize-imports": "^3.2.2", 16 | "run-pty": "^4.0.3", 17 | "typescript": "^4.9.5", 18 | "vite": "^4.1.1", 19 | "vite-plugin-pwa": "^0.14.3" 20 | }, 21 | "scripts": { 22 | "postinstall": "elm-tooling install", 23 | "dev": "run-pty % elm-watch hot % vite", 24 | "build": "elm-watch make --optimize app && vite build", 25 | "preview": "vite preview", 26 | "eslint": "eslint ./src", 27 | "type-check": "tsc -p ./src --noemit", 28 | "elm-review": "elm-review", 29 | "lint": "npm run type-check; npm run eslint; npm run elm-review" 30 | }, 31 | "dependencies": { 32 | "@sentry/browser": "^7.37.1", 33 | "@sentry/tracing": "^7.37.1", 34 | "crypto-random-string": "^5.0.0", 35 | "dexie": "^3.2.3", 36 | "dexie-export-import": "^4.0.6", 37 | "downloadjs": "^1.4.7", 38 | "howler": "^2.2.3", 39 | "nosleep.js": "^0.12.0", 40 | "pkce-challenge": "^3.0.0", 41 | "typescript-json-decoder": "^1.0.11" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/js/logs.ts: -------------------------------------------------------------------------------- 1 | import { ElmApp, LogPayload, ToLogPayload } from "../globals"; 2 | import db from "./helpers/db"; 3 | 4 | const monthlyLogs = (millis: number) => { 5 | const date = new Date(millis); 6 | const firstDay = new Date(date.getFullYear(), date.getMonth(), 1, 0, 0, 0); 7 | const lastDay = new Date( 8 | date.getFullYear(), 9 | date.getMonth() + 1, 10 | 0, 11 | 23, 12 | 59, 13 | 59 14 | ); 15 | 16 | return db.cycles 17 | .where("start") 18 | .between(firstDay.getTime(), lastDay.getTime()) 19 | .toArray(); 20 | }; 21 | 22 | const fetch = async (app: ElmApp, millis: number) => { 23 | const logs = await monthlyLogs(millis); 24 | 25 | app.ports.gotFromLog.send({ ts: millis, logs: logs }); 26 | }; 27 | 28 | const updateSentiment = async (time: number, sentiment: string) => { 29 | const session = await db.cycles.where({ start: time }).toArray(); 30 | 31 | if (!session[0]?.id) { 32 | return; 33 | } 34 | 35 | const updated = await db.cycles.update(session[0].id, { 36 | sentiment: sentiment, 37 | }); 38 | 39 | return updated; 40 | }; 41 | 42 | export default function(app: ElmApp) { 43 | app.ports.toLog.subscribe(async (data: ToLogPayload) => { 44 | switch (data.type) { 45 | case "fetch": 46 | await fetch(app, data.time); 47 | break; 48 | 49 | case "sentiment": 50 | await updateSentiment(data.time, data.sentiment); 51 | break; 52 | } 53 | }); 54 | 55 | app.ports.log.subscribe((data: LogPayload) => db.cycles.add(data)); 56 | } 57 | -------------------------------------------------------------------------------- /review/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "src" 5 | ], 6 | "elm-version": "0.19.1", 7 | "dependencies": { 8 | "direct": { 9 | "elm/core": "1.0.5", 10 | "elm/json": "1.1.3", 11 | "elm/project-metadata-utils": "1.0.2", 12 | "jfmengels/elm-review": "2.12.2", 13 | "jfmengels/elm-review-code-style": "1.1.3", 14 | "jfmengels/elm-review-common": "1.3.2", 15 | "jfmengels/elm-review-debug": "1.0.8", 16 | "jfmengels/elm-review-documentation": "2.0.3", 17 | "jfmengels/elm-review-simplify": "2.0.26", 18 | "jfmengels/elm-review-unused": "1.1.29", 19 | "stil4m/elm-syntax": "7.2.9" 20 | }, 21 | "indirect": { 22 | "elm/bytes": "1.0.8", 23 | "elm/html": "1.0.0", 24 | "elm/parser": "1.1.0", 25 | "elm/random": "1.0.0", 26 | "elm/regex": "1.0.0", 27 | "elm/time": "1.0.0", 28 | "elm/virtual-dom": "1.0.3", 29 | "elm-community/list-extra": "8.7.0", 30 | "elm-explorations/test": "2.1.1", 31 | "miniBill/elm-unicode": "1.0.3", 32 | "pzp1997/assoc-list": "1.0.0", 33 | "rtfeldman/elm-hex": "1.0.0", 34 | "stil4m/structured-writer": "1.0.3" 35 | } 36 | }, 37 | "test-dependencies": { 38 | "direct": { 39 | "elm-explorations/test": "2.1.1" 40 | }, 41 | "indirect": {} 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Color.elm: -------------------------------------------------------------------------------- 1 | module Color exposing 2 | ( Color 3 | , new 4 | , setAlpha 5 | , toCssColor 6 | , toRgbaString 7 | ) 8 | 9 | import Css 10 | import Misc 11 | 12 | 13 | type Color 14 | = Color 15 | { red : Int 16 | , green : Int 17 | , blue : Int 18 | , alpha : Float 19 | } 20 | 21 | 22 | normalizeColor : Int -> Int 23 | normalizeColor color = 24 | if color < 0 then 25 | 0 26 | 27 | else if color > 255 then 28 | 255 29 | 30 | else 31 | color 32 | 33 | 34 | normalizeAlpha : Float -> Float 35 | normalizeAlpha alpha = 36 | if alpha < 0 then 37 | 0 38 | 39 | else if alpha > 1.0 then 40 | 1.0 41 | 42 | else 43 | alpha 44 | 45 | 46 | new : Int -> Int -> Int -> Float -> Color 47 | new red green blue alpha = 48 | Color 49 | { red = normalizeColor red 50 | , green = normalizeColor green 51 | , blue = normalizeColor blue 52 | , alpha = normalizeAlpha alpha 53 | } 54 | 55 | 56 | toCssColor : Color -> Css.Color 57 | toCssColor (Color { red, green, blue, alpha }) = 58 | Css.rgba red green blue alpha 59 | 60 | 61 | toRgbaString : Color -> String 62 | toRgbaString (Color { red, green, blue, alpha }) = 63 | [ red, green, blue ] 64 | |> List.map String.fromInt 65 | |> String.join "," 66 | |> Misc.flip (++) ("," ++ String.fromFloat alpha) 67 | |> (\c -> "rgba(" ++ c ++ ")") 68 | 69 | 70 | setAlpha : Float -> Color -> Color 71 | setAlpha alpha (Color color) = 72 | { color | alpha = alpha } |> Color 73 | -------------------------------------------------------------------------------- /mask-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 17 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /review/src/ReviewConfig.elm: -------------------------------------------------------------------------------- 1 | module ReviewConfig exposing (config) 2 | 3 | {-| Do not rename the ReviewConfig module or the config function, because 4 | `elm-review` will look for these. 5 | 6 | To add packages that contain rules, add them to this review project using 7 | 8 | `elm install author/packagename` 9 | 10 | when inside the directory containing this file. 11 | 12 | -} 13 | 14 | 15 | import Docs.ReviewAtDocs 16 | import NoDebug.Log 17 | import NoDebug.TodoOrToString 18 | import NoExposingEverything 19 | import NoImportingEverything 20 | import NoMissingTypeAnnotation 21 | import NoMissingTypeAnnotationInLetIn 22 | import NoMissingTypeExpose 23 | import NoPrematureLetComputation 24 | import NoSimpleLetBody 25 | import NoUnused.CustomTypeConstructorArgs 26 | import NoUnused.CustomTypeConstructors 27 | import NoUnused.Dependencies 28 | import NoUnused.Exports 29 | import NoUnused.Modules 30 | import NoUnused.Parameters 31 | import NoUnused.Patterns 32 | import NoUnused.Variables 33 | import Review.Rule as Rule exposing (Rule) 34 | import Simplify 35 | 36 | 37 | config : List Rule 38 | config = 39 | [ Docs.ReviewAtDocs.rule 40 | , NoDebug.Log.rule 41 | , NoDebug.TodoOrToString.rule 42 | |> Rule.ignoreErrorsForDirectories [ "tests/" ] 43 | , NoExposingEverything.rule 44 | , NoImportingEverything.rule [] 45 | , NoMissingTypeAnnotation.rule 46 | , NoMissingTypeAnnotationInLetIn.rule 47 | , NoMissingTypeExpose.rule 48 | , NoSimpleLetBody.rule 49 | , NoPrematureLetComputation.rule 50 | , NoUnused.CustomTypeConstructors.rule [] 51 | , NoUnused.CustomTypeConstructorArgs.rule 52 | , NoUnused.Dependencies.rule 53 | , NoUnused.Exports.rule 54 | , NoUnused.Modules.rule 55 | , NoUnused.Parameters.rule 56 | , NoUnused.Patterns.rule 57 | , NoUnused.Variables.rule 58 | , Simplify.rule Simplify.defaults 59 | ] 60 | -------------------------------------------------------------------------------- /src/js/decoders.ts: -------------------------------------------------------------------------------- 1 | import { 2 | array, 3 | DecoderFunction, 4 | decodeType, 5 | nullable, 6 | number, 7 | record, 8 | string, 9 | } from "typescript-json-decoder"; 10 | import { Result, resultErr, resultOk } from "./result"; 11 | 12 | export function decodeWith( 13 | decoder: DecoderFunction, 14 | data: unknown 15 | ): Result { 16 | try { 17 | const decoded = decoder(data); 18 | 19 | return resultOk(decoded); 20 | } catch (e: unknown) { 21 | let error = "Unknown error"; 22 | 23 | if (typeof e === "string") { 24 | error = e; 25 | } 26 | 27 | error += `| DATA: ${JSON.stringify(data)}`; 28 | 29 | return resultErr(new Error(error)); 30 | } 31 | } 32 | 33 | export const spotifyPlaylist = record({ 34 | items: array( 35 | record({ 36 | uri: string, 37 | name: string, 38 | }) 39 | ), 40 | }); 41 | 42 | export const spotifyApiToken = record({ 43 | access_token: string, 44 | token_type: string, 45 | scope: string, 46 | expires_in: number, 47 | refresh_token: string, 48 | }); 49 | 50 | export type SpotifyApiToken = decodeType; 51 | 52 | export const authData = record({ 53 | access_token: string, 54 | token_type: string, 55 | scope: string, 56 | expires_in: number, 57 | expires_at: number, 58 | refresh_token: string, 59 | }); 60 | 61 | export type AuthData = decodeType; 62 | 63 | export const spotifyConnectData = record({ 64 | url: string, 65 | state: string, 66 | code_verifier: string, 67 | code_challenge: string, 68 | }); 69 | 70 | export type SpotifyConnectData = decodeType; 71 | 72 | export const playbackState = record({ 73 | progress_ms: nullable(number), 74 | context: record({ 75 | uri: string, 76 | }), 77 | item: record({ 78 | uri: string, 79 | }), 80 | }); 81 | 82 | export type PlaybackState = decodeType; 83 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 35 | 37 | 42 | 50 | 51 | 55 | 58 | 62 | 66 | 67 | 68 | 69 | -------------------------------------------------------------------------------- /src/Misc.elm: -------------------------------------------------------------------------------- 1 | module Misc exposing 2 | ( TypeAndStrings 3 | , addCmd 4 | , decodePosix 5 | , encodableToType 6 | , encodeMaybe 7 | , encodePosix 8 | , flip 9 | , fromPairs 10 | , maybeTrio 11 | , toPairs 12 | , typeToEncodable 13 | , updateWith 14 | , withCmd 15 | ) 16 | 17 | import Json.Decode as Decode 18 | import Json.Encode as Encode 19 | import List.Extra 20 | import Time 21 | import Tuple.Trio as Trio 22 | 23 | 24 | {-| The idea of this type is to create a standard pattern when 25 | joining union types constructors with their respectives strings, 26 | beign the first a "encodable" representation and the other a 27 | display representation. 28 | -} 29 | type alias TypeAndStrings a = 30 | List ( a, String, String ) 31 | 32 | 33 | typeToEncodable : TypeAndStrings a -> a -> Maybe String 34 | typeToEncodable list type_ = 35 | list 36 | |> List.Extra.find (Trio.first >> (==) type_) 37 | |> Maybe.map Trio.second 38 | 39 | 40 | encodableToType : TypeAndStrings a -> String -> Maybe a 41 | encodableToType list str = 42 | list 43 | |> List.Extra.find (Trio.second >> (==) str) 44 | |> Maybe.map Trio.first 45 | 46 | 47 | addCmd : Cmd msg -> ( a, Cmd msg ) -> ( a, Cmd msg ) 48 | addCmd cmd = 49 | Tuple.mapSecond (\c -> Cmd.batch [ cmd, c ]) 50 | 51 | 52 | withCmd : a -> ( a, Cmd msg ) 53 | withCmd a = 54 | ( a, Cmd.none ) 55 | 56 | 57 | updateWith : (subMsg -> msg) -> ( a, Cmd subMsg ) -> ( a, Cmd msg ) 58 | updateWith fn ( a, cmd ) = 59 | ( a, Cmd.map fn cmd ) 60 | 61 | 62 | toPairs : (a -> String) -> List a -> List ( a, String ) 63 | toPairs fn = 64 | List.map (\a -> ( a, fn a )) 65 | 66 | 67 | fromPairs : List ( a, String ) -> String -> Maybe a 68 | fromPairs list s = 69 | list 70 | |> List.Extra.find (Tuple.second >> (==) s) 71 | |> Maybe.map Tuple.first 72 | 73 | 74 | flip : (b -> a -> c) -> a -> b -> c 75 | flip fn a b = 76 | fn b a 77 | 78 | 79 | maybeTrio : ( Maybe a, Maybe b, Maybe c ) -> Maybe ( a, b, c ) 80 | maybeTrio ( a, b, c ) = 81 | case ( a, b, c ) of 82 | ( Just a_, Just b_, Just c_ ) -> 83 | Just ( a_, b_, c_ ) 84 | 85 | _ -> 86 | Nothing 87 | 88 | 89 | encodeMaybe : (a -> Encode.Value) -> Maybe a -> Encode.Value 90 | encodeMaybe fn value = 91 | case value of 92 | Just a -> 93 | fn a 94 | 95 | Nothing -> 96 | Encode.null 97 | 98 | 99 | encodePosix : Time.Posix -> Encode.Value 100 | encodePosix = 101 | Time.posixToMillis >> Encode.int 102 | 103 | 104 | decodePosix : Decode.Decoder Time.Posix 105 | decodePosix = 106 | Decode.map Time.millisToPosix Decode.int 107 | -------------------------------------------------------------------------------- /src/Spotify.elm: -------------------------------------------------------------------------------- 1 | module Spotify exposing (SpotifyPlaylist, State(..), decodeState, default, encodeState) 2 | 3 | import Json.Decode as Decode 4 | import Json.Encode as Encode 5 | import Misc 6 | 7 | 8 | type alias SpotifyPlaylist = 9 | ( String, String ) 10 | 11 | 12 | type State 13 | = NotConnected String 14 | | ConnectionError String 15 | | Connected (List SpotifyPlaylist) (Maybe String) 16 | | Uninitialized 17 | 18 | 19 | default : State 20 | default = 21 | Uninitialized 22 | 23 | 24 | encodeSpotifyPlaylist : SpotifyPlaylist -> Encode.Value 25 | encodeSpotifyPlaylist ( uri, title ) = 26 | Encode.object 27 | [ ( "uri", Encode.string uri ) 28 | , ( "title", Encode.string title ) 29 | ] 30 | 31 | 32 | decodeSpotifyPlaylist : Decode.Decoder SpotifyPlaylist 33 | decodeSpotifyPlaylist = 34 | Decode.map2 Tuple.pair 35 | (Decode.field "uri" Decode.string) 36 | (Decode.field "title" Decode.string) 37 | 38 | 39 | encodeState : State -> Encode.Value 40 | encodeState state = 41 | case state of 42 | NotConnected url -> 43 | Encode.object 44 | [ ( "type", Encode.string "notconnected" ) 45 | , ( "url", Encode.string url ) 46 | ] 47 | 48 | ConnectionError url -> 49 | Encode.object 50 | [ ( "type", Encode.string "connectionerror" ) 51 | , ( "url", Encode.string url ) 52 | ] 53 | 54 | Connected playlists playlist -> 55 | Encode.object 56 | [ ( "type", Encode.string "connected" ) 57 | , ( "playlists", Encode.list encodeSpotifyPlaylist playlists ) 58 | , ( "playlist", Misc.encodeMaybe Encode.string playlist ) 59 | ] 60 | 61 | Uninitialized -> 62 | Encode.object 63 | [ ( "type", Encode.string "uninitialized" ) ] 64 | 65 | 66 | decodeState : Decode.Decoder State 67 | decodeState = 68 | Decode.field "type" Decode.string 69 | |> Decode.andThen 70 | (\type_ -> 71 | case type_ of 72 | "uninitialized" -> 73 | Decode.succeed Uninitialized 74 | 75 | "notconnected" -> 76 | Decode.map NotConnected <| Decode.field "url" Decode.string 77 | 78 | "connectionerror" -> 79 | Decode.map ConnectionError <| Decode.field "url" Decode.string 80 | 81 | "connected" -> 82 | Decode.map2 Connected 83 | (Decode.field "playlists" (Decode.list decodeSpotifyPlaylist)) 84 | (Decode.field "playlist" (Decode.nullable Decode.string)) 85 | 86 | _ -> 87 | Decode.fail <| "Invalid spotify state of: " ++ type_ 88 | ) 89 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | export interface SpotifyDef { 2 | connected: boolean; 3 | canPlay: boolean; 4 | playing: boolean; 5 | deviceId: string | null; 6 | } 7 | 8 | export interface LocalStoragePayload { 9 | key: string; 10 | data: unknown; 11 | } 12 | 13 | type LocalStorageSubscribe = (payload: LocalStoragePayload) => void; 14 | 15 | interface Notifications { 16 | inapp: boolean; 17 | sound: boolean; 18 | browser: boolean; 19 | } 20 | 21 | interface NotifyPayload { 22 | sound: string; 23 | msg: string; 24 | config: Notifications; 25 | } 26 | 27 | type NotifySubscribe = (config: NotifyPayload) => void; 28 | 29 | export type ToLogPayload = 30 | | { type: "fetch"; time: number } 31 | | { type: "sentiment"; time: number; sentiment: string }; 32 | 33 | type ToLogSubscribe = (payload: ToLogPayload) => void; 34 | 35 | export interface LogPayload { 36 | interval: { 37 | type: string; 38 | secs: number; 39 | }; 40 | start: number | null; 41 | end: number | null; 42 | secs: number | null; 43 | sentiment: string | null; 44 | } 45 | 46 | type LogSubscribe = (payload: LogPayload) => void; 47 | 48 | export type ToSettingsPayload = 49 | | { type: "requestExport" } 50 | | { type: "import"; data: string } 51 | | { type: "delete" } 52 | | { type: "testAlarm"; data: string } 53 | | { type: "browserPermission"; data: boolean }; 54 | 55 | type ToSettingsSubscribe = (payload: ToSettingsSubscribe) => void; 56 | 57 | export type ToSpotifyPayload = 58 | | { type: "play"; url: string } 59 | | { type: "pause" } 60 | | { type: "refresh" } 61 | | { type: "disconnect" }; 62 | 63 | type ToSpotifySubscribe = (payload: ToSpotifyPayload) => void; 64 | 65 | export interface ElmApp { 66 | ports: { 67 | // ports 68 | localStorage: { 69 | subscribe(LocalStorageSubscribe): void; 70 | }; 71 | notify: { 72 | subscribe(NotifySubscribe): void; 73 | }; 74 | toLog: { 75 | subscribe(ToLogSubscribe): void; 76 | }; 77 | log: { 78 | subscribe(LogSubscribe): void; 79 | }; 80 | toSettings: { 81 | subscribe(ToSettingsSubscribe): void; 82 | }; 83 | toSpotify: { 84 | subscribe(ToSpotifySubscribe): void; 85 | }; 86 | 87 | // subscriptions 88 | tick: { 89 | send(unknown): void; 90 | }; 91 | gotFlashMsg: { 92 | send(unknown): void; 93 | }; 94 | gotFromLog: { 95 | send(unknown): void; 96 | }; 97 | gotFromSettings: { 98 | send(unknown): void; 99 | }; 100 | gotFromSpotify: { 101 | send(unknown): void; 102 | }; 103 | }; 104 | } 105 | 106 | interface ElmAppConstructor { 107 | Main: { 108 | init(unknown): ElmApp; 109 | }; 110 | } 111 | 112 | declare global { 113 | interface Window { 114 | spotifyPlayerLoaded: boolean; 115 | spotify: SpotifyDef; 116 | Elm: ElmAppConstructor; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/js/settings.ts: -------------------------------------------------------------------------------- 1 | import { peakImportFile } from "dexie-export-import"; 2 | import download from "downloadjs"; 3 | import { ElmApp, ToSettingsPayload } from "../globals"; 4 | import alarmSounds from "./helpers/alarm-sounds.js"; 5 | import db from "./helpers/db"; 6 | import setFlash from "./helpers/flash.js"; 7 | 8 | const testSound = (sound: string): void => { 9 | if (alarmSounds[sound]) { 10 | alarmSounds[sound]?.play(); 11 | } 12 | }; 13 | 14 | const requestNotif = async (app: ElmApp, activate: boolean) => { 15 | if (!activate) { 16 | return app.ports.gotFromSettings.send({ val: false, msg: "" }); 17 | } 18 | 19 | let permission = Notification.permission; 20 | 21 | if (permission == "denied") { 22 | return app.ports.gotFromSettings.send({ 23 | val: false, 24 | msg: "You have blocked browser notifications for this app. Change your browser settings to allow new notifications.", 25 | }); 26 | } 27 | 28 | if (permission == "granted") { 29 | return app.ports.gotFromSettings.send({ val: true, msg: "" }); 30 | } 31 | 32 | permission = await Notification.requestPermission(); 33 | 34 | if (permission == "granted") { 35 | return app.ports.gotFromSettings.send({ val: true, msg: "" }); 36 | } 37 | 38 | return app.ports.gotFromSettings.send({ 39 | val: false, 40 | msg: "You have blocked browser notifications for this app. Change your browser settings to allow notifications.", 41 | }); 42 | }; 43 | 44 | const importData = async (app: ElmApp, str: string) => { 45 | const blob = new Blob([str]); 46 | 47 | try { 48 | await peakImportFile(blob); 49 | await db.cycles.clear(); 50 | await db.import(blob); 51 | 52 | setFlash(app, "Data has been successfully imported."); 53 | } catch (e) { 54 | setFlash(app, "There was an error trying to import the data."); 55 | } 56 | }; 57 | 58 | const exportData = async () => { 59 | const blob = await db.export(); 60 | 61 | download(blob, "pelmodoro-data.json", "application/json"); 62 | }; 63 | 64 | const clearLogs = () => { 65 | const confirmed = confirm( 66 | "Are you sure you wanna delete all log entries? This step is irreversible!" 67 | ); 68 | 69 | if (!confirmed) { 70 | return; 71 | } 72 | 73 | void db.cycles.clear(); 74 | }; 75 | 76 | export default function(app: ElmApp) { 77 | app.ports.toSettings.subscribe(async (data: ToSettingsPayload) => { 78 | switch (data.type) { 79 | case "testAlarm": 80 | testSound(data.data); 81 | break; 82 | 83 | case "browserPermission": 84 | await requestNotif(app, data.data); 85 | break; 86 | 87 | case "import": 88 | await importData(app, data.data); 89 | break; 90 | 91 | case "requestExport": 92 | await exportData(); 93 | break; 94 | 95 | case "delete": 96 | clearLogs(); 97 | break; 98 | } 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /src/Theme.elm: -------------------------------------------------------------------------------- 1 | module Theme exposing 2 | ( backgroundColor 3 | , breakColor 4 | , contrastColor 5 | , decodeTheme 6 | , encodeTheme 7 | , foregroundColor 8 | , longBreakColor 9 | , textColor 10 | , themeTypeAndStrings 11 | , workColor 12 | ) 13 | 14 | import Color 15 | import Json.Decode as Decode 16 | import Json.Encode as Encode 17 | import Misc 18 | import Theme.Common 19 | import Theme.Dracula as Dracula 20 | import Theme.Fall as Fall 21 | import Theme.Gruvbox as Gruvbox 22 | import Theme.NightMood as NightMood 23 | import Theme.Nord as Nord 24 | import Theme.Tomato as Tomato 25 | 26 | 27 | themeColors : Theme.Common.Theme -> Theme.Common.ThemeColors 28 | themeColors theme = 29 | case theme of 30 | Theme.Common.Tomato -> 31 | Tomato.theme 32 | 33 | Theme.Common.NightMood -> 34 | NightMood.theme 35 | 36 | Theme.Common.Gruvbox -> 37 | Gruvbox.theme 38 | 39 | Theme.Common.Dracula -> 40 | Dracula.theme 41 | 42 | Theme.Common.Nord -> 43 | Nord.theme 44 | 45 | Theme.Common.Fall -> 46 | Fall.theme 47 | 48 | 49 | backgroundColor : Theme.Common.Theme -> Color.Color 50 | backgroundColor = 51 | themeColors >> .background 52 | 53 | 54 | foregroundColor : Theme.Common.Theme -> Color.Color 55 | foregroundColor = 56 | themeColors >> .foreground 57 | 58 | 59 | textColor : Theme.Common.Theme -> Color.Color 60 | textColor = 61 | themeColors >> .text 62 | 63 | 64 | contrastColor : Theme.Common.Theme -> Color.Color 65 | contrastColor = 66 | themeColors >> .contrast 67 | 68 | 69 | workColor : Theme.Common.Theme -> Color.Color 70 | workColor = 71 | themeColors >> .work 72 | 73 | 74 | breakColor : Theme.Common.Theme -> Color.Color 75 | breakColor = 76 | themeColors >> .break 77 | 78 | 79 | longBreakColor : Theme.Common.Theme -> Color.Color 80 | longBreakColor = 81 | themeColors >> .longBreak 82 | 83 | 84 | themeTypeAndStrings : Misc.TypeAndStrings Theme.Common.Theme 85 | themeTypeAndStrings = 86 | [ ( Theme.Common.Tomato, "light", "Tomato" ) 87 | , ( Theme.Common.NightMood, "dark", "Night Mood" ) 88 | , ( Theme.Common.Gruvbox, "gruvbox", "Gruvbox" ) 89 | , ( Theme.Common.Dracula, "dracula", "Dracula" ) 90 | , ( Theme.Common.Nord, "nord", "Nord" ) 91 | , ( Theme.Common.Fall, "fall", "Fall" ) 92 | ] 93 | 94 | 95 | 96 | -- CODECS 97 | 98 | 99 | encodeTheme : Theme.Common.Theme -> Encode.Value 100 | encodeTheme = 101 | Misc.typeToEncodable themeTypeAndStrings >> Maybe.withDefault "" >> Encode.string 102 | 103 | 104 | decodeTheme : Decode.Decoder Theme.Common.Theme 105 | decodeTheme = 106 | Decode.string 107 | |> Decode.andThen 108 | (Misc.encodableToType themeTypeAndStrings 109 | >> Maybe.map Decode.succeed 110 | >> Maybe.withDefault (Decode.fail "Invalid theme") 111 | ) 112 | -------------------------------------------------------------------------------- /src/Page/MiniTimer.elm: -------------------------------------------------------------------------------- 1 | module Page.MiniTimer exposing (Model, view) 2 | 3 | import Color 4 | import Css 5 | import Html.Styled as Html 6 | import Html.Styled.Attributes as Attributes 7 | import Session 8 | import Theme.Common 9 | 10 | 11 | 12 | -- MODEL 13 | 14 | 15 | type alias Model a b = 16 | { a 17 | | sessions : List Session.RoundType 18 | , active : Session.ActiveRound 19 | , settings : { b | theme : Theme.Common.Theme } 20 | } 21 | 22 | 23 | 24 | -- VIEW 25 | 26 | 27 | view : Model a b -> Html.Html msg 28 | view { sessions, active, settings } = 29 | let 30 | totalRun : Float 31 | totalRun = 32 | sessions |> Session.roundsTotalRun |> toFloat 33 | in 34 | Html.ul 35 | [ Attributes.css 36 | [ Css.width <| Css.pct 100 37 | , Css.displayFlex 38 | , Css.padding <| Css.rem 0.25 39 | , Css.listStyle Css.none 40 | ] 41 | ] 42 | (sessions 43 | |> List.indexedMap 44 | (\index session -> 45 | let 46 | sizeInPct : Float 47 | sizeInPct = 48 | toFloat (Session.roundSeconds session) * 100 / totalRun 49 | 50 | backgroundColor : Color.Color 51 | backgroundColor = 52 | session |> Session.roundToColor settings.theme 53 | 54 | backgroundColor_ : Color.Color 55 | backgroundColor_ = 56 | if index >= active.index then 57 | backgroundColor |> Color.setAlpha 0.25 58 | 59 | else 60 | backgroundColor 61 | in 62 | Html.li 63 | [ Attributes.css 64 | [ Css.width <| Css.pct sizeInPct 65 | , Css.height <| Css.rem 0.5 66 | , Css.margin <| Css.rem 0.25 67 | , Css.borderRadius <| Css.rem 0.25 68 | , Css.backgroundColor <| Color.toCssColor backgroundColor_ 69 | , Css.overflow Css.hidden 70 | ] 71 | ] 72 | [ if index == active.index then 73 | let 74 | elapsedPct : Float 75 | elapsedPct = 76 | Session.elapsedPct active 77 | in 78 | Html.div 79 | [ Attributes.css 80 | [ Css.width <| Css.pct elapsedPct 81 | , Css.height <| Css.pct 100 82 | , Css.backgroundColor <| Color.toCssColor backgroundColor 83 | ] 84 | ] 85 | [] 86 | 87 | else 88 | Html.text "" 89 | ] 90 | ) 91 | ) 92 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pelmodoro 2 | 3 | [**Pelmodoro**](https://www.pelmodoro.com/) is an attempt to create a somewhat feature-complete Pomodoro timer app so you can track your working sessions using the Pomodoro technique. 4 | 5 | It runs in the browser but can also be installed as a stand-alone app as a PWA on mobile devices or other browsers that allow that type of installation like Edge. 6 | 7 | ## What is the Pomodoro technique? 8 | 9 | > The Pomodoro Technique is a time management method developed by Francesco Cirillo in the late 1980s. The technique uses a timer to break down work into intervals, traditionally 25 minutes in length, separated by short breaks. Each interval is known as a pomodoro, from the Italian word for 'tomato', after the tomato-shaped kitchen timer that Cirillo used as a university student. 10 | > -- Pomodoro Technique from Wikipedia 11 | 12 | ## Features 13 | 14 | - Personalize your sessions 15 | - Productivity / sentiment logs 16 | - Stats 17 | - Multiple means of notification like sounds and browser notifications 18 | - Spotify integration to sync a playlist to a working session 19 | - Multiple color themes 20 | - Export your stats data 21 | - And more... 22 | 23 | ## What about the name? 24 | 25 | Pelmodoro started as a side-project to improve my skills in writing [Elm](https://elm-lang.org/), hence the name p**ELM**odoro. I'm not a huge fan of using tech names on product names, but in this case, it just felt right. 26 | 27 | ## Contributing 28 | 29 | Like said before, Pelmodoro is written almost entirely in the Elm language. If you are willing to contribute with code, you might wanna take a look at the language's [guide](https://guide.elm-lang.org/) if you are not familiar with it yet. It is easy and fun, **you should give it a try**! 30 | 31 | For the most part, I consider this project done, but there are few areas where contributions are much appreciated: 32 | 33 | **Outstanding bugs**: If you find any bugs that prevent you from using the app, create an issue describing the problem and we can work it out. 34 | 35 | **New themes**: If you have any ideas for a new theme or just want to import a different color scheme to Pelmodoro, take a look at how themes are implemented [here](https://github.com/eberfreitas/pelmodoro/blob/main/src/Theme/Theme.elm) and [here](https://github.com/eberfreitas/pelmodoro/blob/main/src/Theme/Tomato.elm). New themes are always appreciated. 36 | 37 | **Quotes**: You can contribute with quotes related to productivity, mindfulness, awareness, and so on. Take a look at the quotes we already have [here](https://github.com/eberfreitas/pelmodoro/blob/main/src/Quotes.elm) to get a sense of the type of quotes we use on the app. Try to bring insightful ideas that can aggregate to everyday real life. 38 | 39 | **Alarm sounds**: We can always go with more options. If you find a cool sound, we can add it. Just make sure that we can use it freely. [Freesound](https://freesound.org/) seems to be a good place to find new sounds. 40 | 41 | **Spelling and grammatical errors**: If you spot any words or expressions that are wrong or can be improved, just let me know or send your PR. There are probably a bunch of those as english is not my first language. 42 | 43 | **PWA improvements**: This app is my first attempt at creating a PWA application. I'm sure there are ways to improve things there. 44 | 45 | Other ideas might be discussed on issues, just let me know. 46 | 47 | ## Running locally 48 | 49 | Clone the repo and run the following commands: 50 | 51 | ``` 52 | $ cp .env.sample .env 53 | $ npm install 54 | $ npm run dev 55 | ``` 56 | 57 | Now go to `https://localhost:1234` and you should see the app running. 58 | 59 | You can also build the project running: 60 | 61 | ``` 62 | $ npm run build 63 | ``` 64 | 65 | Built artifacts will be available in the `/dist` folder. 66 | -------------------------------------------------------------------------------- /src/Page/Flash.elm: -------------------------------------------------------------------------------- 1 | module Page.Flash exposing 2 | ( FlashMsg 3 | , Model 4 | , Msg 5 | , empty 6 | , new 7 | , setFlash 8 | , subscriptions 9 | , update 10 | , updateFlashTime 11 | , view 12 | ) 13 | 14 | import Color 15 | import Css 16 | import Elements 17 | import Html.Styled as Html 18 | import Html.Styled.Attributes as Attributes 19 | import Html.Styled.Events as Events 20 | import Json.Decode as Decode 21 | import Misc 22 | import Ports 23 | import Theme 24 | import Theme.Common 25 | 26 | 27 | 28 | -- MODEL 29 | 30 | 31 | type alias Model a = 32 | { a | flash : Maybe FlashMsg } 33 | 34 | 35 | type alias FlashMsg = 36 | { time : Int 37 | , msg : String 38 | } 39 | 40 | 41 | 42 | -- VIEW 43 | 44 | 45 | view : Theme.Common.Theme -> FlashMsg -> Html.Html Msg 46 | view theme { msg, time } = 47 | let 48 | containerStyles : Css.Style 49 | containerStyles = 50 | Css.batch 51 | [ Css.padding <| Css.rem 1 52 | , Css.backgroundColor (theme |> Theme.foregroundColor |> Color.toCssColor) 53 | , Css.color (theme |> Theme.contrastColor |> Color.toCssColor) 54 | , Css.margin2 Css.zero Css.auto 55 | , Css.width <| Css.pct 100 56 | , Css.maxWidth <| Css.rem 40 57 | , Css.position Css.relative 58 | ] 59 | in 60 | Html.div 61 | [ Attributes.css 62 | [ Css.position Css.absolute 63 | , Css.top Css.zero 64 | , Css.width <| Css.pct 100 65 | ] 66 | ] 67 | [ Html.div 68 | [ Attributes.css [ containerStyles ] ] 69 | [ Html.span [] [ Html.text msg ] 70 | , Html.div 71 | [ Attributes.css 72 | [ Css.position Css.absolute 73 | , Css.right <| Css.rem 0.8 74 | , Css.top <| Css.rem 0.8 75 | ] 76 | ] 77 | [ Html.span [ Attributes.css [ Css.fontSize <| Css.rem 0.75 ] ] [ Html.text (String.fromInt time) ] 78 | , Html.button 79 | [ Events.onClick Close 80 | , Attributes.css 81 | [ Css.margin Css.zero 82 | , Css.border <| Css.rem 0 83 | , Css.backgroundColor Css.transparent 84 | , Css.color (theme |> Theme.contrastColor |> Color.toCssColor) 85 | , Css.display Css.inlineBlock 86 | , Css.marginLeft <| Css.rem 0.5 87 | , Css.verticalAlign Css.middle 88 | , Css.cursor Css.pointer 89 | ] 90 | ] 91 | [ Elements.icon "highlight_off" ] 92 | ] 93 | ] 94 | ] 95 | 96 | 97 | 98 | -- UPDATE 99 | 100 | 101 | type Msg 102 | = Close 103 | | GotMsg Decode.Value 104 | 105 | 106 | update : Msg -> Model a -> ( Model a, Cmd msg ) 107 | update msg model = 108 | case msg of 109 | Close -> 110 | { model | flash = Nothing } |> Misc.withCmd 111 | 112 | GotMsg raw -> 113 | raw 114 | |> Decode.decodeValue decodeFlashMsg 115 | |> Result.map (\f -> { model | flash = Just f }) 116 | |> Result.withDefault model 117 | |> Misc.withCmd 118 | 119 | 120 | 121 | -- HELPERS 122 | 123 | 124 | new : String -> FlashMsg 125 | new content = 126 | FlashMsg 15 content 127 | 128 | 129 | empty : FlashMsg 130 | empty = 131 | FlashMsg 0 "" 132 | 133 | 134 | setFlash : Maybe FlashMsg -> Model a -> Model a 135 | setFlash flashMsg model = 136 | flashMsg 137 | |> Maybe.map (\f -> { model | flash = Just f }) 138 | |> Maybe.withDefault model 139 | 140 | 141 | updateFlashTime : FlashMsg -> Maybe FlashMsg 142 | updateFlashTime ({ time } as msg) = 143 | if (time - 1) < 1 then 144 | Nothing 145 | 146 | else 147 | Just { msg | time = time - 1 } 148 | 149 | 150 | 151 | -- SUBSCRIPTIONS 152 | 153 | 154 | subscriptions : Sub Msg 155 | subscriptions = 156 | Ports.gotFlashMsg GotMsg 157 | 158 | 159 | 160 | -- CODECS 161 | 162 | 163 | decodeFlashMsg : Decode.Decoder FlashMsg 164 | decodeFlashMsg = 165 | Decode.field "msg" Decode.string |> Decode.map new 166 | -------------------------------------------------------------------------------- /src/Page/Credits.elm: -------------------------------------------------------------------------------- 1 | module Page.Credits exposing (Model, view) 2 | 3 | import Color 4 | import Css 5 | import Elements 6 | import Html.Styled as Html 7 | import Html.Styled.Attributes as Attributes 8 | import Page.MiniTimer as MiniTimer 9 | import Session 10 | import Settings 11 | import Theme 12 | 13 | 14 | 15 | -- MODEL 16 | 17 | 18 | type alias Model a = 19 | { a 20 | | sessions : List Session.RoundType 21 | , settings : Settings.Settings 22 | , active : Session.ActiveRound 23 | } 24 | 25 | 26 | 27 | -- VIEW 28 | 29 | 30 | view : Model a -> Html.Html msg 31 | view ({ settings } as model) = 32 | let 33 | anchorStyle : Css.Style 34 | anchorStyle = 35 | Css.batch 36 | [ Css.color (settings.theme |> Theme.textColor |> Color.toCssColor) 37 | , Css.fontWeight Css.bold 38 | ] 39 | 40 | sectionStyle : Css.Style 41 | sectionStyle = 42 | Css.batch 43 | [ Css.lineHeight <| Css.num 1.5 44 | , Css.marginBottom <| Css.rem 2 45 | , Css.textAlign Css.center 46 | ] 47 | 48 | anchor : String -> String -> Html.Html msg 49 | anchor url text = 50 | Html.a 51 | [ Attributes.href url 52 | , Attributes.target "_blank" 53 | , Attributes.css [ anchorStyle ] 54 | ] 55 | [ Html.text text ] 56 | 57 | strong : String -> Html.Html msg 58 | strong text = 59 | Html.strong [] [ Html.text text ] 60 | 61 | h2 : String -> Html.Html msg 62 | h2 text = 63 | Elements.h2 settings.theme text [ Attributes.css [ Css.marginBottom <| Css.rem 1 ] ] [] 64 | in 65 | Html.div [] 66 | [ MiniTimer.view model 67 | , Html.div 68 | [ Attributes.css 69 | [ Css.margin2 (Css.rem 2) Css.auto 70 | , Css.maxWidth <| Css.px 520 71 | ] 72 | ] 73 | [ Elements.h1 settings.theme "Credits" 74 | , Html.div 75 | [ Attributes.css [ sectionStyle ] ] 76 | [ Html.text "Created by " 77 | , anchor "https://www.eberfdias.com" "Éber F. Dias" 78 | , Html.text " with " 79 | , anchor "https://elm-lang.org/" "Elm" 80 | , Html.text " (and other things). You can check the source code @ " 81 | , anchor "https://github.com/eberfreitas/pelmodoro" "GitHub" 82 | , Html.text "." 83 | ] 84 | , h2 "Themes" 85 | , Html.div 86 | [ Attributes.css [ sectionStyle ] ] 87 | [ Html.p [] 88 | [ strong "Gruvbox" 89 | , Html.text " originally by " 90 | , anchor "https://github.com/morhetz/gruvbox" "Pavel Pertsev" 91 | ] 92 | , Html.p [] 93 | [ strong "Dracula" 94 | , Html.text " originally by " 95 | , anchor "https://github.com/dracula/dracula-theme" "Zeno Rocha" 96 | ] 97 | , Html.p [] 98 | [ strong "Nord" 99 | , Html.text " originally by " 100 | , anchor "https://github.com/arcticicestudio/nord" "Arctic Ice Studio" 101 | ] 102 | ] 103 | , h2 "Sounds" 104 | , Html.div 105 | [ Attributes.css [ sectionStyle ] ] 106 | [ Html.p [] 107 | [ strong "Wind Chimes, A.wav" 108 | , Html.text " by " 109 | , anchor "https://freesound.org/people/InspectorJ/sounds/353194/" "InspectorJ" 110 | ] 111 | , Html.p [] 112 | [ strong "Alarm Bell" 113 | , Html.text " by " 114 | , anchor "https://freesound.org/people/DDmyzik/sounds/460262/" "DDmyzik" 115 | ] 116 | , Html.p [] 117 | [ strong "Alarm1.mp3" 118 | , Html.text " by " 119 | , anchor "https://freesound.org/people/kwahmah_02/sounds/250629/" "kwahmah_02" 120 | ] 121 | , Html.p [] 122 | [ strong "bong.wav" 123 | , Html.text " by " 124 | , anchor "https://freesound.org/people/OtisJames/sounds/215774/" "OtisJames" 125 | ] 126 | , Html.p [] 127 | [ strong "Relaxing Percussion.wav" 128 | , Html.text " by " 129 | , anchor "https://freesound.org/people/AndreAngelo/sounds/246201/" "AndreAngelo" 130 | ] 131 | , Html.p [] 132 | [ strong "Birdsong Singleshot Denoised Wakeup Alarm" 133 | , Html.text " by " 134 | , anchor "https://freesound.org/people/unfa/sounds/186024/" "unfa" 135 | ] 136 | ] 137 | , Html.div 138 | [ Attributes.css [ sectionStyle ] ] 139 | [ anchor "https://www.buymeacoffee.com/eberfre" "🍕 buy me a pizza" 140 | ] 141 | ] 142 | ] 143 | -------------------------------------------------------------------------------- /src/Page/Spotify.elm: -------------------------------------------------------------------------------- 1 | module Page.Spotify exposing 2 | ( Msg 3 | , pause 4 | , play 5 | , subscriptions 6 | , update 7 | , view 8 | ) 9 | 10 | import Css 11 | import Elements 12 | import Html.Styled as Html 13 | import Html.Styled.Attributes as Attributes 14 | import Html.Styled.Events as Events 15 | import Json.Decode as Decode 16 | import Json.Encode as Encode 17 | import List.Extra 18 | import Misc 19 | import Ports 20 | import Spotify 21 | import Theme.Common 22 | 23 | 24 | view : Theme.Common.Theme -> Spotify.State -> Html.Html Msg 25 | view theme state = 26 | Html.div [ Attributes.css [ Css.marginBottom <| Css.rem 2 ] ] 27 | [ Html.div [ Attributes.css [ Elements.labelStyle ] ] [ Html.text "Spotify" ] 28 | , Html.div [] 29 | (case state of 30 | Spotify.NotConnected url -> 31 | [ Elements.largeLinkButton theme url "Connect to Spotify " ] 32 | 33 | Spotify.ConnectionError url -> 34 | [ Html.p [ Attributes.css [ Css.marginBottom <| Css.rem 1 ] ] 35 | [ Html.text "There was an error trying to connect. Please, try again!" ] 36 | , Elements.largeLinkButton theme url "Connect to Spotify" 37 | ] 38 | 39 | Spotify.Connected playlists current -> 40 | [ Html.select [ Attributes.css [ Elements.selectStyle theme ], Events.onInput UpdatePlaylist ] 41 | (playlists 42 | |> List.sortBy Tuple.second 43 | |> List.map 44 | (\( uri, title ) -> 45 | Html.option 46 | [ Attributes.value uri, Attributes.selected (current == Just uri) ] 47 | [ Html.text title ] 48 | ) 49 | |> (::) (Html.option [ Attributes.value "" ] [ Html.text "--" ]) 50 | |> (::) 51 | (Html.option 52 | [ Attributes.value "", Attributes.selected (current == Nothing) ] 53 | [ Html.text "Don't play anything" ] 54 | ) 55 | ) 56 | |> Elements.simpleSeparator 57 | , Elements.largeButton theme RefreshPlaylists [ Html.text "Refresh playlists" ] 58 | |> Elements.simpleSeparator 59 | , Elements.largeButton theme Disconnect [ Html.text "Disconnect" ] 60 | ] 61 | 62 | Spotify.Uninitialized -> 63 | [ Html.text "Can't connect to Spotify" ] 64 | ) 65 | ] 66 | 67 | 68 | 69 | -- UPDATE 70 | 71 | 72 | type Msg 73 | = GotState Decode.Value 74 | | RefreshPlaylists 75 | | Disconnect 76 | | UpdatePlaylist String 77 | 78 | 79 | update : Msg -> Spotify.State -> ( Spotify.State, Cmd msg ) 80 | update msg state = 81 | case msg of 82 | GotState raw -> 83 | case Decode.decodeValue Spotify.decodeState raw of 84 | Ok protoState -> 85 | case ( state, protoState ) of 86 | ( Spotify.Connected _ (Just playlist), Spotify.Connected playlists _ ) -> 87 | let 88 | newPlaylist : Maybe String 89 | newPlaylist = 90 | playlists 91 | |> List.Extra.find (Tuple.first >> (==) playlist) 92 | |> Maybe.map Tuple.first 93 | in 94 | Spotify.Connected playlists newPlaylist |> Misc.withCmd 95 | 96 | _ -> 97 | protoState |> Misc.withCmd 98 | 99 | Err _ -> 100 | Misc.withCmd state 101 | 102 | RefreshPlaylists -> 103 | state 104 | |> Misc.withCmd 105 | |> Misc.addCmd (Refresh |> toPort) 106 | 107 | Disconnect -> 108 | state 109 | |> Misc.withCmd 110 | |> Misc.addCmd (Disconn |> toPort) 111 | 112 | UpdatePlaylist playlist -> 113 | case state of 114 | Spotify.Connected playlists _ -> 115 | playlists 116 | |> List.Extra.find (Tuple.first >> (==) playlist) 117 | |> Maybe.map Tuple.first 118 | |> Spotify.Connected playlists 119 | |> Misc.withCmd 120 | 121 | _ -> 122 | Misc.withCmd state 123 | 124 | 125 | 126 | -- HELPERS 127 | 128 | 129 | play : Spotify.State -> Cmd msg 130 | play state = 131 | case state of 132 | Spotify.Connected _ url -> 133 | url 134 | |> Maybe.map (Play >> toPort) 135 | |> Maybe.withDefault Cmd.none 136 | 137 | _ -> 138 | Cmd.none 139 | 140 | 141 | pause : Spotify.State -> Cmd msg 142 | pause state = 143 | case state of 144 | Spotify.Connected _ _ -> 145 | Pause |> toPort 146 | 147 | _ -> 148 | Cmd.none 149 | 150 | 151 | 152 | -- PORTS INTERFACE 153 | 154 | 155 | type PortAction 156 | = Play String 157 | | Pause 158 | | Refresh 159 | | Disconn 160 | 161 | 162 | encodePortAction : PortAction -> Encode.Value 163 | encodePortAction action = 164 | case action of 165 | Play url -> 166 | Encode.object 167 | [ ( "type", Encode.string "play" ) 168 | , ( "url", Encode.string url ) 169 | ] 170 | 171 | Pause -> 172 | Encode.object [ ( "type", Encode.string "pause" ) ] 173 | 174 | Refresh -> 175 | Encode.object [ ( "type", Encode.string "refresh" ) ] 176 | 177 | Disconn -> 178 | Encode.object [ ( "type", Encode.string "disconnect" ) ] 179 | 180 | 181 | toPort : PortAction -> Cmd msg 182 | toPort = 183 | encodePortAction >> Ports.toSpotify 184 | 185 | 186 | 187 | -- SUBSCRIPTIONS 188 | 189 | 190 | subscriptions : Sub Msg 191 | subscriptions = 192 | Ports.gotFromSpotify GotState 193 | 194 | 195 | 196 | -- CODECS 197 | -------------------------------------------------------------------------------- /src/Settings.elm: -------------------------------------------------------------------------------- 1 | module Settings exposing 2 | ( AlarmSound(..) 3 | , Flow(..) 4 | , NotificationType(..) 5 | , Notifications 6 | , Settings 7 | , alarmSoundToEncodable 8 | , alarmSoundTypeAndStrings 9 | , decodeBrowserNotificationPermission 10 | , decodeSettings 11 | , default 12 | , encodeNotifications 13 | , encodeSettings 14 | , flowTypeAndStrings 15 | , shouldKeepPlaying 16 | , toggleNotification 17 | ) 18 | 19 | import Json.Decode as Decode 20 | import Json.Decode.Pipeline as Pipeline 21 | import Json.Encode as Encode 22 | import Misc 23 | import Spotify 24 | import Theme 25 | import Theme.Common 26 | 27 | 28 | type Flow 29 | = None 30 | | Simple 31 | | Loop 32 | 33 | 34 | type NotificationType 35 | = InApp 36 | | AlarmSound 37 | | Browser 38 | 39 | 40 | type alias Notifications = 41 | { inApp : Bool 42 | , alarmSound : Bool 43 | , browser : Bool 44 | } 45 | 46 | 47 | type AlarmSound 48 | = WindChimes 49 | | Bell 50 | | AlarmClock 51 | | Bong 52 | | RelaxingPercussion 53 | | BirdSong 54 | 55 | 56 | type alias Settings = 57 | { rounds : Int 58 | , workDuration : Int 59 | , breakDuration : Int 60 | , longBreakDuration : Int 61 | , theme : Theme.Common.Theme 62 | , flow : Flow 63 | , spotify : Spotify.State 64 | , notifications : Notifications 65 | , alarmSound : AlarmSound 66 | } 67 | 68 | 69 | notificationsDefault : Notifications 70 | notificationsDefault = 71 | Notifications True True False 72 | 73 | 74 | default : Settings 75 | default = 76 | Settings 77 | 4 78 | (25 * 60) 79 | (5 * 60) 80 | (15 * 60) 81 | Theme.Common.Tomato 82 | None 83 | Spotify.default 84 | notificationsDefault 85 | WindChimes 86 | 87 | 88 | toggleNotification : NotificationType -> Notifications -> Notifications 89 | toggleNotification type_ notification = 90 | case type_ of 91 | InApp -> 92 | { notification | inApp = not notification.inApp } 93 | 94 | AlarmSound -> 95 | { notification | alarmSound = not notification.alarmSound } 96 | 97 | Browser -> 98 | { notification | browser = not notification.browser } 99 | 100 | 101 | shouldKeepPlaying : Int -> Flow -> Bool 102 | shouldKeepPlaying index flow = 103 | case ( index, flow ) of 104 | ( _, None ) -> 105 | False 106 | 107 | ( 0, Simple ) -> 108 | False 109 | 110 | ( 0, Loop ) -> 111 | True 112 | 113 | _ -> 114 | True 115 | 116 | 117 | flowTypeAndStrings : Misc.TypeAndStrings Flow 118 | flowTypeAndStrings = 119 | [ ( None, "nocont", "No automatic flow" ) 120 | , ( Simple, "simplecont", "Simple flow" ) 121 | , ( Loop, "fullcont", "Non-stop flow" ) 122 | ] 123 | 124 | 125 | alarmSoundTypeAndStrings : Misc.TypeAndStrings AlarmSound 126 | alarmSoundTypeAndStrings = 127 | [ ( WindChimes, "wind-chimes", "Wind Chimes" ) 128 | , ( Bell, "bell", "Bell" ) 129 | , ( AlarmClock, "alarm-clock", "Alarm Clock" ) 130 | , ( Bong, "bong", "Bong" ) 131 | , ( RelaxingPercussion, "relaxing-percussion", "Relaxing Percussion" ) 132 | , ( BirdSong, "bird-song", "Bird Song" ) 133 | ] 134 | 135 | 136 | alarmSoundToEncodable : AlarmSound -> String 137 | alarmSoundToEncodable = 138 | Misc.typeToEncodable alarmSoundTypeAndStrings >> Maybe.withDefault "" 139 | 140 | 141 | encodeFlow : Flow -> Encode.Value 142 | encodeFlow = 143 | Misc.typeToEncodable flowTypeAndStrings >> Maybe.withDefault "" >> Encode.string 144 | 145 | 146 | decodeFlow : Decode.Decoder Flow 147 | decodeFlow = 148 | Decode.string 149 | |> Decode.andThen 150 | (Misc.encodableToType flowTypeAndStrings 151 | >> Maybe.map Decode.succeed 152 | >> Maybe.withDefault (Decode.fail "Invalid flow") 153 | ) 154 | 155 | 156 | encodeNotifications : Notifications -> Encode.Value 157 | encodeNotifications { inApp, alarmSound, browser } = 158 | Encode.object 159 | [ ( "inapp", Encode.bool inApp ) 160 | , ( "sound", Encode.bool alarmSound ) 161 | , ( "browser", Encode.bool browser ) 162 | ] 163 | 164 | 165 | decodeNotifications : Decode.Decoder Notifications 166 | decodeNotifications = 167 | Decode.succeed Notifications 168 | |> Pipeline.required "inapp" Decode.bool 169 | |> Pipeline.required "sound" Decode.bool 170 | |> Pipeline.required "browser" Decode.bool 171 | 172 | 173 | encodeAlarmSound : AlarmSound -> Encode.Value 174 | encodeAlarmSound = 175 | Misc.typeToEncodable alarmSoundTypeAndStrings >> Maybe.withDefault "" >> Encode.string 176 | 177 | 178 | decodeAlarmSound : Decode.Decoder AlarmSound 179 | decodeAlarmSound = 180 | Decode.string 181 | |> Decode.andThen 182 | (Misc.encodableToType alarmSoundTypeAndStrings 183 | >> Maybe.map Decode.succeed 184 | >> Maybe.withDefault (Decode.fail "Invalid alarm sound") 185 | ) 186 | 187 | 188 | encodeSettings : Settings -> Encode.Value 189 | encodeSettings settings = 190 | Encode.object 191 | [ ( "rounds", Encode.int settings.rounds ) 192 | , ( "activity", Encode.int settings.workDuration ) 193 | , ( "break", Encode.int settings.breakDuration ) 194 | , ( "longBreak", Encode.int settings.longBreakDuration ) 195 | , ( "theme", Theme.encodeTheme settings.theme ) 196 | , ( "continuity", encodeFlow settings.flow ) 197 | , ( "spotify", Spotify.encodeState settings.spotify ) 198 | , ( "notifications", encodeNotifications settings.notifications ) 199 | , ( "sound", encodeAlarmSound settings.alarmSound ) 200 | ] 201 | 202 | 203 | decodeSettings : Decode.Decoder Settings 204 | decodeSettings = 205 | Decode.succeed Settings 206 | |> Pipeline.required "rounds" Decode.int 207 | |> Pipeline.required "activity" Decode.int 208 | |> Pipeline.required "break" Decode.int 209 | |> Pipeline.required "longBreak" Decode.int 210 | |> Pipeline.required "theme" Theme.decodeTheme 211 | |> Pipeline.required "continuity" decodeFlow 212 | |> Pipeline.required "spotify" Spotify.decodeState 213 | |> Pipeline.optional "notifications" decodeNotifications notificationsDefault 214 | |> Pipeline.optional "sound" decodeAlarmSound WindChimes 215 | 216 | 217 | decodeBrowserNotificationPermission : Decode.Decoder { val : Bool, msg : String } 218 | decodeBrowserNotificationPermission = 219 | Decode.map2 220 | (\val msg -> { val = val, msg = msg }) 221 | (Decode.field "val" Decode.bool) 222 | (Decode.field "msg" Decode.string) 223 | -------------------------------------------------------------------------------- /src/Elements.elm: -------------------------------------------------------------------------------- 1 | module Elements exposing 2 | ( checkbox 3 | , h1 4 | , h2 5 | , h3 6 | , icon 7 | , inputContainer 8 | , labelStyle 9 | , largeButton 10 | , largeLinkButton 11 | , numberInput 12 | , selectInput 13 | , selectStyle 14 | , simpleSeparator 15 | , styledIcon 16 | ) 17 | 18 | import Color 19 | import Css 20 | import Html.Styled as Html 21 | import Html.Styled.Attributes as Attributes 22 | import Html.Styled.Events as Events 23 | import Theme 24 | import Theme.Common 25 | 26 | 27 | icon : String -> Html.Html msg 28 | icon desc = 29 | Html.span [ Attributes.class "material-icons-round" ] [ Html.text desc ] 30 | 31 | 32 | styledIcon : List Css.Style -> String -> Html.Html msg 33 | styledIcon styles desc = 34 | Html.span 35 | [ Attributes.class "material-icons-round" 36 | , Attributes.css styles 37 | ] 38 | [ Html.text desc ] 39 | 40 | 41 | h1 : Theme.Common.Theme -> String -> Html.Html msg 42 | h1 theme label = 43 | Html.h1 44 | [ Attributes.css 45 | [ Css.fontSize <| Css.rem 2 46 | , Css.color (theme |> Theme.textColor |> Color.toCssColor) 47 | , Css.marginBottom <| Css.rem 2 48 | , Css.textAlign Css.center 49 | ] 50 | ] 51 | [ Html.text label ] 52 | 53 | 54 | h2 : Theme.Common.Theme -> String -> List (Html.Attribute msg) -> List (Html.Html msg) -> Html.Html msg 55 | h2 theme label props children = 56 | Html.h2 57 | (Attributes.css 58 | [ Css.fontSize <| Css.rem 1.5 59 | , Css.textAlign <| Css.center 60 | , Css.color (theme |> Theme.textColor |> Color.toCssColor) 61 | ] 62 | :: props 63 | ) 64 | (Html.text label :: children) 65 | 66 | 67 | h3 : Theme.Common.Theme -> String -> List (Html.Attribute msg) -> List (Html.Html msg) -> Html.Html msg 68 | h3 theme label props children = 69 | Html.h3 70 | (Attributes.css 71 | [ Css.fontSize <| Css.rem 1 72 | , Css.textAlign <| Css.center 73 | , Css.color (theme |> Theme.textColor |> Color.toCssColor) 74 | ] 75 | :: props 76 | ) 77 | (Html.text label :: children) 78 | 79 | 80 | buttonStyle : Theme.Common.Theme -> Css.Style 81 | buttonStyle theme = 82 | Css.batch 83 | [ Css.borderStyle Css.none 84 | , Css.backgroundColor <| (theme |> Theme.foregroundColor |> Color.toCssColor) 85 | , Css.width <| Css.rem 3 86 | , Css.height <| Css.rem 3 87 | , Css.color <| (theme |> Theme.backgroundColor |> Color.toCssColor) 88 | , Css.outline Css.zero 89 | , Css.cursor Css.pointer 90 | ] 91 | 92 | 93 | largeButtonStyle : Theme.Common.Theme -> Css.Style 94 | largeButtonStyle theme = 95 | Css.batch 96 | [ buttonStyle theme 97 | , Css.display Css.block 98 | , Css.width <| Css.pct 100 99 | , Css.textAlign Css.center 100 | , Css.textDecoration Css.none 101 | , Css.fontSize <| Css.rem 1 102 | , Css.fontFamilies [ "Montserrat" ] 103 | ] 104 | 105 | 106 | largeButton : Theme.Common.Theme -> msg -> List (Html.Html msg) -> Html.Html msg 107 | largeButton theme msg body = 108 | Html.button 109 | [ Events.onClick msg 110 | , Attributes.css [ largeButtonStyle theme ] 111 | ] 112 | body 113 | 114 | 115 | largeLinkButton : Theme.Common.Theme -> String -> String -> Html.Html msg 116 | largeLinkButton theme url label = 117 | Html.a 118 | [ Attributes.href url 119 | , Attributes.css 120 | [ largeButtonStyle theme 121 | , Css.paddingTop <| Css.rem 1 122 | ] 123 | ] 124 | [ Html.text label ] 125 | 126 | 127 | selectStyle : Theme.Common.Theme -> Css.Style 128 | selectStyle theme = 129 | Css.batch 130 | [ Css.property "appearance" "none" 131 | , Css.borderStyle Css.none 132 | , Css.fontFamilies [ "Montserrat" ] 133 | , Css.fontSize <| Css.rem 1 134 | , Css.padding <| Css.rem 1 135 | , Css.width <| Css.pct 100 136 | , Css.cursor Css.pointer 137 | , Css.color (theme |> Theme.textColor |> Color.toCssColor) 138 | , Css.backgroundColor (theme |> Theme.contrastColor |> Color.toCssColor) 139 | , Css.backgroundRepeat Css.noRepeat 140 | , Css.backgroundPosition2 (Css.pct 95) (Css.pct 50) 141 | , Css.property "background-image" 142 | "url(\"data:image/svg+xml;utf8,\")" 143 | ] 144 | 145 | 146 | selectInput : 147 | Theme.Common.Theme 148 | -> (( a, String, String ) -> Bool) 149 | -> (String -> msg) 150 | -> List ( a, String, String ) 151 | -> Html.Html msg 152 | selectInput theme selectedFn toMsg trios = 153 | Html.div [] 154 | [ Html.select [ Attributes.css [ selectStyle theme ], Events.onInput toMsg ] 155 | (trios 156 | |> List.map 157 | (\(( _, v, l ) as def) -> 158 | Html.option 159 | [ Attributes.value v, Attributes.selected (selectedFn def) ] 160 | [ Html.text l ] 161 | ) 162 | ) 163 | ] 164 | 165 | 166 | labelStyle : Css.Style 167 | labelStyle = 168 | Css.batch 169 | [ Css.fontSize <| Css.rem 1.2 170 | , Css.marginBottom <| Css.rem 1 171 | , Css.fontWeight <| Css.bold 172 | ] 173 | 174 | 175 | checkbox : Theme.Common.Theme -> Bool -> msg -> String -> Html.Html msg 176 | checkbox theme val msg label = 177 | let 178 | icon_ : String 179 | icon_ = 180 | if val then 181 | "check_box" 182 | 183 | else 184 | "check_box_outline_blank" 185 | in 186 | Html.div [] 187 | [ Html.button 188 | [ Attributes.css 189 | [ Css.backgroundColor Css.transparent 190 | , Css.border Css.zero 191 | , Css.padding Css.zero 192 | , Css.margin Css.zero 193 | , Css.verticalAlign Css.middle 194 | , Css.cursor Css.pointer 195 | , Css.color (theme |> Theme.foregroundColor |> Color.toCssColor) 196 | ] 197 | , Events.onClick msg 198 | ] 199 | [ icon icon_ ] 200 | , Html.span [ Events.onClick msg ] [ Html.text label ] 201 | ] 202 | 203 | 204 | atLeast : Int -> Int -> Int 205 | atLeast target num = 206 | if num < target then 207 | target 208 | 209 | else 210 | num 211 | 212 | 213 | atMost : Int -> Int -> Int 214 | atMost target num = 215 | if num > target then 216 | target 217 | 218 | else 219 | num 220 | 221 | 222 | numberInputStyle : Theme.Common.Theme -> Css.Style 223 | numberInputStyle theme = 224 | Css.batch 225 | [ Css.height <| Css.rem 3 226 | , Css.backgroundColor (theme |> Theme.contrastColor |> Color.toCssColor) 227 | , Css.color (theme |> Theme.textColor |> Color.toCssColor) 228 | , Css.padding2 (Css.rem 1) (Css.rem 0) 229 | , Css.width (Css.calc (Css.pct 100) Css.minus (Css.rem 6)) 230 | , Css.textAlign Css.center 231 | ] 232 | 233 | 234 | numberInput : Theme.Common.Theme -> Int -> Int -> (Int -> msg) -> Int -> Html.Html msg 235 | numberInput theme min max msg val = 236 | Html.div 237 | [ Attributes.css [ Css.displayFlex ] ] 238 | [ Html.button 239 | [ Attributes.css [ buttonStyle theme ], Events.onClick (val - 1 |> atLeast min |> msg) ] 240 | [ icon "remove" ] 241 | , Html.div 242 | [ Attributes.css [ numberInputStyle theme ] ] 243 | [ Html.text <| String.fromInt val ] 244 | , Html.button 245 | [ Attributes.css [ buttonStyle theme ], Events.onClick (val + 1 |> atMost max |> msg) ] 246 | [ icon "add" ] 247 | ] 248 | 249 | 250 | inputContainer : String -> Html.Html msg -> Html.Html msg 251 | inputContainer label input = 252 | Html.div [ Attributes.css [ Css.marginBottom <| Css.rem 2 ] ] 253 | [ Html.div [ Attributes.css [ labelStyle ] ] [ Html.text label ] 254 | , input 255 | ] 256 | 257 | 258 | simpleSeparator : Html.Html msg -> Html.Html msg 259 | simpleSeparator body = 260 | Html.div [ Attributes.css [ Css.marginBottom <| Css.rem 1 ] ] [ body ] 261 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | // "jsx": "preserve", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "es2022", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "resolveJsonModule": true, /* Enable importing .json files. */ 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | // "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | // "outDir": "./", /* Specify an output folder for all emitted files. */ 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 77 | 78 | /* Type Checking */ 79 | "strict": true, /* Enable all strict type-checking options. */ 80 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (Flags, Model, Msg, Page, main) 2 | 3 | import Browser 4 | import Browser.Navigation as Navigation 5 | import Color 6 | import Css 7 | import Elements 8 | import Html.Styled as Html 9 | import Html.Styled.Attributes as Attributes 10 | import Json.Decode as Decode 11 | import Misc 12 | import Page.Credits as CreditsPage 13 | import Page.Flash as FlashPage 14 | import Page.Settings as SettingsPage 15 | import Page.Stats as StatsPage 16 | import Page.Timer as TimerPage 17 | import Platform.Sub as Sub 18 | import Session 19 | import Settings 20 | import Task 21 | import Theme 22 | import Theme.Common 23 | import Time 24 | import Url 25 | import VirtualDom 26 | 27 | 28 | 29 | -- MODEL 30 | 31 | 32 | type alias Model = 33 | { zone : Time.Zone 34 | , time : Time.Posix 35 | , key : Navigation.Key 36 | , page : Page 37 | , uptime : Int 38 | , playing : Bool 39 | , settings : Settings.Settings 40 | , sessions : List Session.RoundType 41 | , active : Session.ActiveRound 42 | , sentimentSession : Maybe Session.Round 43 | , flash : Maybe FlashPage.FlashMsg 44 | } 45 | 46 | 47 | type Page 48 | = TimerPage 49 | | StatsPage StatsPage.State 50 | | SettingsPage 51 | | CreditsPage 52 | 53 | 54 | type alias Flags = 55 | { active : Decode.Value 56 | , settings : Decode.Value 57 | , now : Int 58 | } 59 | 60 | 61 | 62 | -- INIT 63 | 64 | 65 | init : Flags -> Url.Url -> Navigation.Key -> ( Model, Cmd Msg ) 66 | init { active, settings, now } url key = 67 | let 68 | baseModel : Model 69 | baseModel = 70 | default key 71 | 72 | newActive : Session.ActiveRound 73 | newActive = 74 | case Decode.decodeValue Session.decodeActiveRound active of 75 | Ok active_ -> 76 | active_ 77 | 78 | Err _ -> 79 | baseModel.active 80 | 81 | newSettings : Settings.Settings 82 | newSettings = 83 | case Decode.decodeValue Settings.decodeSettings settings of 84 | Ok settings_ -> 85 | settings_ 86 | 87 | Err _ -> 88 | baseModel.settings 89 | 90 | time : Time.Posix 91 | time = 92 | Time.millisToPosix now 93 | 94 | ( newRounds, newActive_ ) = 95 | Session.buildRounds newSettings (Just newActive) 96 | 97 | ( page, pageCmd ) = 98 | urlToPage time url 99 | in 100 | ( { baseModel 101 | | active = newActive_ 102 | , time = Time.millisToPosix now 103 | , settings = newSettings 104 | , sessions = newRounds 105 | , page = page 106 | } 107 | , Cmd.batch 108 | [ Task.perform AdjustTimeZone Time.here 109 | , pageCmd 110 | ] 111 | ) 112 | 113 | 114 | 115 | -- VIEW 116 | 117 | 118 | view : Model -> Browser.Document Msg 119 | view model = 120 | let 121 | title : List String 122 | title = 123 | case model.page of 124 | TimerPage -> 125 | if model.playing then 126 | [ model.active |> Session.secondsLeft |> truncate |> TimerPage.secondsToDisplay 127 | , Session.roundToString model.active.round.type_ 128 | ] 129 | 130 | else 131 | [] 132 | 133 | SettingsPage -> 134 | [ "Settings" ] 135 | 136 | StatsPage _ -> 137 | [ "Stats" ] 138 | 139 | CreditsPage -> 140 | [ "Credits" ] 141 | in 142 | { title = title ++ [ "Pelmodoro" ] |> String.join " - " 143 | , body = [ viewBody model ] 144 | } 145 | 146 | 147 | viewBody : Model -> VirtualDom.Node Msg 148 | viewBody model = 149 | Html.div 150 | [ Attributes.class "container" 151 | , Attributes.css 152 | [ Css.width <| Css.vw 100.0 153 | , Css.position Css.relative 154 | , Css.backgroundColor <| (model.settings.theme |> Theme.backgroundColor |> Color.toCssColor) 155 | , Css.fontFamilies [ "Montserrat" ] 156 | , Css.color (model.settings.theme |> Theme.textColor |> Color.toCssColor) 157 | ] 158 | ] 159 | [ viewPage model 160 | , viewFlash model.settings.theme model.flash 161 | , viewNav model.settings.theme model.page 162 | ] 163 | |> Html.toUnstyled 164 | 165 | 166 | viewFlash : Theme.Common.Theme -> Maybe FlashPage.FlashMsg -> Html.Html Msg 167 | viewFlash theme flash = 168 | flash 169 | |> Maybe.map (\f -> FlashPage.view theme f |> Html.map Flash) 170 | |> Maybe.withDefault (Html.text "") 171 | 172 | 173 | viewNav : Theme.Common.Theme -> Page -> Html.Html Msg 174 | viewNav theme page = 175 | let 176 | pages : List ( String, String ) 177 | pages = 178 | [ ( "/", "timer" ) 179 | , ( "/stats", "leaderboard" ) 180 | , ( "/settings", "settings" ) 181 | , ( "/credits", "info" ) 182 | ] 183 | 184 | buttonStyle : Css.Style 185 | buttonStyle = 186 | Css.batch 187 | [ Css.borderStyle Css.none 188 | , Css.backgroundColor Css.transparent 189 | , Css.width <| Css.rem 3 190 | , Css.height <| Css.rem 3 191 | , Css.color <| (theme |> Theme.backgroundColor |> Color.toCssColor) 192 | , Css.outline Css.zero 193 | , Css.displayFlex 194 | , Css.justifyContent Css.center 195 | , Css.alignItems Css.center 196 | , Css.textDecoration Css.none 197 | ] 198 | 199 | isSelected : String -> Page -> Css.Style 200 | isSelected path current = 201 | case ( path, current ) of 202 | ( "/", TimerPage ) -> 203 | Css.opacity <| Css.num 1 204 | 205 | ( "/settings", SettingsPage ) -> 206 | Css.opacity <| Css.num 1 207 | 208 | ( "/stats", StatsPage _ ) -> 209 | Css.opacity <| Css.num 1 210 | 211 | ( "/credits", CreditsPage ) -> 212 | Css.opacity <| Css.num 1 213 | 214 | _ -> 215 | Css.opacity <| Css.num 0.4 216 | in 217 | Html.div 218 | [ Attributes.css 219 | [ Css.position Css.absolute 220 | , Css.bottom <| Css.px 0 221 | , Css.left <| Css.px 0 222 | , Css.right <| Css.px 0 223 | , Css.backgroundColor <| (theme |> Theme.foregroundColor |> Color.toCssColor) 224 | , Css.color <| (theme |> Theme.foregroundColor |> Color.toCssColor) 225 | , Css.displayFlex 226 | , Css.justifyContent Css.center 227 | , Css.padding <| Css.rem 0.25 228 | ] 229 | ] 230 | [ Html.ul 231 | [ Attributes.css 232 | [ Css.displayFlex 233 | , Css.justifyContent Css.center 234 | , Css.listStyle Css.none 235 | ] 236 | ] 237 | (pages 238 | |> List.map 239 | (\( path, icon ) -> 240 | Html.li [] 241 | [ Html.a 242 | [ Attributes.href path 243 | , Attributes.css 244 | [ buttonStyle 245 | , isSelected path page 246 | ] 247 | ] 248 | [ Elements.icon icon ] 249 | ] 250 | ) 251 | ) 252 | ] 253 | 254 | 255 | viewPage : Model -> Html.Html Msg 256 | viewPage model = 257 | Html.div 258 | [ Attributes.css 259 | [ Css.height (Css.calc (Css.pct 100) Css.minus (Css.rem 3.5)) 260 | , Css.overflow Css.auto 261 | ] 262 | ] 263 | [ case model.page of 264 | TimerPage -> 265 | TimerPage.view model |> Html.map Timer 266 | 267 | SettingsPage -> 268 | SettingsPage.view model |> Html.map Settings 269 | 270 | StatsPage state -> 271 | StatsPage.view model state |> Html.map Stats 272 | 273 | CreditsPage -> 274 | CreditsPage.view model 275 | ] 276 | 277 | 278 | 279 | -- UPDATE 280 | 281 | 282 | type Msg 283 | = AdjustTimeZone Time.Zone 284 | | UrlChanged Url.Url 285 | | LinkCliked Browser.UrlRequest 286 | | Timer TimerPage.Msg 287 | | Stats StatsPage.Msg 288 | | Settings SettingsPage.Msg 289 | | Flash FlashPage.Msg 290 | 291 | 292 | update : Msg -> Model -> ( Model, Cmd Msg ) 293 | update msg model = 294 | case ( msg, model.page ) of 295 | ( AdjustTimeZone newZone, _ ) -> 296 | Misc.withCmd { model | zone = newZone } 297 | 298 | ( UrlChanged url, _ ) -> 299 | url 300 | |> urlToPage model.time 301 | |> Tuple.mapFirst (\p -> { model | page = p }) 302 | 303 | ( LinkCliked urlRequest, _ ) -> 304 | case urlRequest of 305 | Browser.Internal url -> 306 | ( model, Navigation.pushUrl model.key (Url.toString url) ) 307 | 308 | Browser.External href -> 309 | ( model, Navigation.load href ) 310 | 311 | ( Flash subMsg, _ ) -> 312 | FlashPage.update subMsg model |> Misc.updateWith Flash 313 | 314 | ( Timer subMsg, _ ) -> 315 | TimerPage.update subMsg model 316 | 317 | ( Stats subMsg, StatsPage state ) -> 318 | StatsPage.update model.zone subMsg state 319 | |> Tuple.mapFirst (\s -> { model | page = StatsPage s }) 320 | |> Misc.updateWith Stats 321 | 322 | ( Settings subMsg, _ ) -> 323 | SettingsPage.update subMsg model |> Misc.updateWith Settings 324 | 325 | _ -> 326 | Misc.withCmd model 327 | 328 | 329 | 330 | -- HELPERS 331 | 332 | 333 | default : Navigation.Key -> Model 334 | default key = 335 | let 336 | ( rounds, active ) = 337 | Session.buildRounds Settings.default Nothing 338 | in 339 | { zone = Time.utc 340 | , time = Time.millisToPosix 0 341 | , key = key 342 | , page = TimerPage 343 | , uptime = 0 344 | , playing = False 345 | , settings = Settings.default 346 | , sessions = rounds 347 | , active = active 348 | , sentimentSession = Nothing 349 | , flash = Nothing 350 | } 351 | 352 | 353 | urlToPage : Time.Posix -> Url.Url -> ( Page, Cmd Msg ) 354 | urlToPage time { path } = 355 | case path of 356 | "/settings" -> 357 | ( SettingsPage, Cmd.none ) 358 | 359 | "/stats" -> 360 | ( StatsPage StatsPage.initialState, time |> StatsPage.logsFetchCmd ) 361 | 362 | "/credits" -> 363 | ( CreditsPage, Cmd.none ) 364 | 365 | _ -> 366 | ( TimerPage, Cmd.none ) 367 | 368 | 369 | 370 | -- SUBSCRIPTIONS 371 | 372 | 373 | subscriptions : Model -> Sub Msg 374 | subscriptions _ = 375 | Sub.batch 376 | [ TimerPage.subscriptions |> Sub.map Timer 377 | , SettingsPage.subscriptions |> Sub.map Settings 378 | , StatsPage.subscriptions |> Sub.map Stats 379 | , FlashPage.subscriptions |> Sub.map Flash 380 | ] 381 | 382 | 383 | 384 | -- MAIN 385 | 386 | 387 | main : Program Flags Model Msg 388 | main = 389 | Browser.application 390 | { init = init 391 | , view = view 392 | , update = update 393 | , subscriptions = subscriptions 394 | , onUrlChange = UrlChanged 395 | , onUrlRequest = LinkCliked 396 | } 397 | -------------------------------------------------------------------------------- /src/Session.elm: -------------------------------------------------------------------------------- 1 | module Session exposing 2 | ( ActiveRound 3 | , Round 4 | , RoundType(..) 5 | , Sentiment 6 | , addElapsed 7 | , buildRounds 8 | , calculateSentiment 9 | , decodeActiveRound 10 | , decodeRound 11 | , elapsedPct 12 | , encodeActiveRound 13 | , encodeSentiment 14 | , firstRound 15 | , isAnyBreak 16 | , isWork 17 | , logRound 18 | , materializedRound 19 | , negative 20 | , neutral 21 | , newActiveRound 22 | , newRound 23 | , positive 24 | , roundChangeToLabel 25 | , roundSeconds 26 | , roundStart 27 | , roundToColor 28 | , roundToString 29 | , roundsTotalRun 30 | , saveActive 31 | , secondsLeft 32 | , sentimentToDisplay 33 | , sentimentToIcon 34 | , setRoundStart 35 | ) 36 | 37 | import Color 38 | import Json.Decode as Decode 39 | import Json.Decode.Pipeline as Pipeline 40 | import Json.Encode as Encode 41 | import List.Extra 42 | import Misc 43 | import Ports 44 | import String.Extra 45 | import Theme 46 | import Theme.Common 47 | import Time 48 | 49 | 50 | 51 | -- type alias Session = 52 | -- { active : ActiveRound 53 | -- , playing : Bool 54 | -- , uptime : Int 55 | -- , rounds : List RoundType 56 | -- } 57 | 58 | 59 | type RoundType 60 | = Work Int 61 | | Break Int 62 | | LongBreak Int 63 | 64 | 65 | type Sentiment 66 | = Positive 67 | | Neutral 68 | | Negative 69 | 70 | 71 | type alias Round = 72 | { type_ : RoundType 73 | , start : Maybe Time.Posix 74 | , end : Maybe Time.Posix 75 | , seconds : Maybe Int 76 | , sentiment : Maybe Sentiment 77 | } 78 | 79 | 80 | type alias ActiveRound = 81 | { index : Int 82 | , round : Round 83 | , elapsed : Int 84 | } 85 | 86 | 87 | sendToLog : Round -> Cmd msg 88 | sendToLog session = 89 | session |> encodeRound |> Ports.log 90 | 91 | 92 | saveActive : ActiveRound -> Cmd msg 93 | saveActive active = 94 | active |> encodeActiveRound |> Ports.localStorageHelper "active" 95 | 96 | 97 | firstRound : List RoundType -> RoundType 98 | firstRound = 99 | List.head >> Maybe.withDefault (Work (25 * 60)) 100 | 101 | 102 | newRound : RoundType -> Round 103 | newRound round = 104 | Round round Nothing Nothing Nothing Nothing 105 | 106 | 107 | newActiveRound : List RoundType -> ActiveRound 108 | newActiveRound rounds = 109 | ActiveRound 0 (newRound (firstRound rounds)) 0 110 | 111 | 112 | setRoundStart : Time.Posix -> Round -> Round 113 | setRoundStart now round = 114 | { round | start = Just now } 115 | 116 | 117 | buildRounds : 118 | { a | workDuration : Int, breakDuration : Int, longBreakDuration : Int, rounds : Int } 119 | -> Maybe ActiveRound 120 | -> ( List RoundType, ActiveRound ) 121 | buildRounds settings active = 122 | let 123 | rounds : List RoundType 124 | rounds = 125 | settings.workDuration 126 | |> Work 127 | |> List.repeat settings.rounds 128 | |> List.intersperse (Break settings.breakDuration) 129 | |> Misc.flip (++) [ LongBreak settings.longBreakDuration ] 130 | 131 | baseActive : ActiveRound 132 | baseActive = 133 | newActiveRound rounds 134 | 135 | newActive : ActiveRound 136 | newActive = 137 | active 138 | |> Maybe.map 139 | (\({ index, round } as curr) -> 140 | case List.Extra.getAt index rounds of 141 | Just i -> 142 | if i == round.type_ then 143 | curr 144 | 145 | else 146 | baseActive 147 | 148 | Nothing -> 149 | baseActive 150 | ) 151 | |> Maybe.withDefault baseActive 152 | in 153 | ( rounds, newActive ) 154 | 155 | 156 | sentimentToString : Sentiment -> String 157 | sentimentToString sentiment = 158 | case sentiment of 159 | Positive -> 160 | "positive" 161 | 162 | Neutral -> 163 | "neutral" 164 | 165 | Negative -> 166 | "negative" 167 | 168 | 169 | sentimentPairs : List ( Sentiment, String ) 170 | sentimentPairs = 171 | [ Positive 172 | , Neutral 173 | , Negative 174 | ] 175 | |> Misc.toPairs sentimentToString 176 | 177 | 178 | sentimentFromString : String -> Maybe Sentiment 179 | sentimentFromString = 180 | Misc.fromPairs sentimentPairs 181 | 182 | 183 | sentimentToDisplay : Sentiment -> String 184 | sentimentToDisplay = 185 | sentimentToString >> String.Extra.toSentenceCase 186 | 187 | 188 | endRound : Time.Posix -> ActiveRound -> Maybe Round 189 | endRound now { round, elapsed } = 190 | if elapsed /= 0 then 191 | Just { round | end = Just now, seconds = Just elapsed } 192 | 193 | else 194 | Nothing 195 | 196 | 197 | logRound : Time.Posix -> ActiveRound -> Cmd msg 198 | logRound now active = 199 | active 200 | |> endRound now 201 | |> Maybe.map sendToLog 202 | |> Maybe.withDefault Cmd.none 203 | 204 | 205 | roundStart : Time.Posix -> Round -> Round 206 | roundStart now round = 207 | { round | start = Just now } 208 | 209 | 210 | materializedRound : 211 | Round 212 | -> 213 | Maybe 214 | { type_ : RoundType 215 | , start : Time.Posix 216 | , end : Time.Posix 217 | , seconds : Int 218 | } 219 | materializedRound { type_, start, end, seconds } = 220 | ( start, end, seconds ) 221 | |> Misc.maybeTrio 222 | |> Maybe.map 223 | (\( start_, end_, secs_ ) -> 224 | { type_ = type_ 225 | , start = start_ 226 | , end = end_ 227 | , seconds = secs_ 228 | } 229 | ) 230 | 231 | 232 | roundSeconds : RoundType -> Int 233 | roundSeconds interval = 234 | case interval of 235 | Work s -> 236 | s 237 | 238 | Break s -> 239 | s 240 | 241 | LongBreak s -> 242 | s 243 | 244 | 245 | roundsTotalRun : List RoundType -> Int 246 | roundsTotalRun rounds = 247 | rounds |> List.foldl (\i t -> i |> roundSeconds |> (+) t) 0 248 | 249 | 250 | isWork : RoundType -> Bool 251 | isWork round = 252 | case round of 253 | Work _ -> 254 | True 255 | 256 | _ -> 257 | False 258 | 259 | 260 | isAnyBreak : RoundType -> Bool 261 | isAnyBreak round = 262 | case round of 263 | Work _ -> 264 | False 265 | 266 | _ -> 267 | True 268 | 269 | 270 | roundToString : RoundType -> String 271 | roundToString round = 272 | case round of 273 | Work _ -> 274 | "Work" 275 | 276 | Break _ -> 277 | "Break" 278 | 279 | LongBreak _ -> 280 | "Long break" 281 | 282 | 283 | secondsLeft : ActiveRound -> Float 284 | secondsLeft { round, elapsed } = 285 | roundSeconds round.type_ - elapsed |> toFloat 286 | 287 | 288 | addElapsed : Int -> ActiveRound -> ActiveRound 289 | addElapsed i active = 290 | { active | elapsed = active.elapsed + i } 291 | 292 | 293 | elapsedPct : ActiveRound -> Float 294 | elapsedPct { round, elapsed } = 295 | toFloat elapsed * 100 / (toFloat <| roundSeconds round.type_) 296 | 297 | 298 | 299 | -- CODECS 300 | 301 | 302 | encodeRoundType : RoundType -> Encode.Value 303 | encodeRoundType def = 304 | case def of 305 | Work s -> 306 | Encode.object 307 | [ ( "type", Encode.string "activity" ) 308 | , ( "secs", Encode.int s ) 309 | ] 310 | 311 | Break s -> 312 | Encode.object 313 | [ ( "type", Encode.string "break" ) 314 | , ( "secs", Encode.int s ) 315 | ] 316 | 317 | LongBreak s -> 318 | Encode.object 319 | [ ( "type", Encode.string "longbreak" ) 320 | , ( "secs", Encode.int s ) 321 | ] 322 | 323 | 324 | decodeRoundType : Decode.Decoder RoundType 325 | decodeRoundType = 326 | Decode.field "type" Decode.string 327 | |> Decode.andThen 328 | (\def -> 329 | case def of 330 | "activity" -> 331 | Decode.map Work <| Decode.field "secs" Decode.int 332 | 333 | "break" -> 334 | Decode.map Break <| Decode.field "secs" Decode.int 335 | 336 | "longbreak" -> 337 | Decode.map LongBreak <| Decode.field "secs" Decode.int 338 | 339 | _ -> 340 | Decode.fail <| "Can't decode interval of type: " ++ def 341 | ) 342 | 343 | 344 | encodeSentiment : Sentiment -> Encode.Value 345 | encodeSentiment = 346 | sentimentToString >> Encode.string 347 | 348 | 349 | decodeSentiment : Decode.Decoder Sentiment 350 | decodeSentiment = 351 | Decode.string 352 | |> Decode.andThen 353 | (sentimentFromString 354 | >> Maybe.map Decode.succeed 355 | >> Maybe.withDefault (Decode.fail "Invalid sentiment") 356 | ) 357 | 358 | 359 | encodeRound : Round -> Encode.Value 360 | encodeRound { type_, start, end, seconds, sentiment } = 361 | Encode.object 362 | [ ( "interval", encodeRoundType type_ ) 363 | , ( "start", Misc.encodeMaybe Misc.encodePosix start ) 364 | , ( "end", Misc.encodeMaybe Misc.encodePosix end ) 365 | , ( "secs", Misc.encodeMaybe Encode.int seconds ) 366 | , ( "sentiment", Misc.encodeMaybe encodeSentiment sentiment ) 367 | ] 368 | 369 | 370 | decodeRound : Decode.Decoder Round 371 | decodeRound = 372 | Decode.succeed Round 373 | |> Pipeline.required "interval" decodeRoundType 374 | |> Pipeline.required "start" (Decode.nullable Misc.decodePosix) 375 | |> Pipeline.required "end" (Decode.nullable Misc.decodePosix) 376 | |> Pipeline.required "secs" (Decode.nullable Decode.int) 377 | |> Pipeline.optional "sentiment" (Decode.nullable decodeSentiment) Nothing 378 | 379 | 380 | encodeActiveRound : ActiveRound -> Encode.Value 381 | encodeActiveRound { index, round, elapsed } = 382 | Encode.object 383 | [ ( "index", Encode.int index ) 384 | , ( "cycle", encodeRound round ) 385 | , ( "elapsed", Encode.int elapsed ) 386 | ] 387 | 388 | 389 | decodeActiveRound : Decode.Decoder ActiveRound 390 | decodeActiveRound = 391 | Decode.succeed ActiveRound 392 | |> Pipeline.required "index" Decode.int 393 | |> Pipeline.required "cycle" decodeRound 394 | |> Pipeline.required "elapsed" Decode.int 395 | 396 | 397 | roundToColor : Theme.Common.Theme -> RoundType -> Color.Color 398 | roundToColor theme round = 399 | case round of 400 | Work _ -> 401 | Theme.workColor theme 402 | 403 | Break _ -> 404 | Theme.breakColor theme 405 | 406 | LongBreak _ -> 407 | Theme.longBreakColor theme 408 | 409 | 410 | positive : Sentiment 411 | positive = 412 | Positive 413 | 414 | 415 | neutral : Sentiment 416 | neutral = 417 | Neutral 418 | 419 | 420 | negative : Sentiment 421 | negative = 422 | Negative 423 | 424 | 425 | roundChangeToLabel : RoundType -> RoundType -> String 426 | roundChangeToLabel from to = 427 | case ( from, to ) of 428 | ( Work _, Break _ ) -> 429 | "Time to take a break" 430 | 431 | ( Break _, Work _ ) -> 432 | "Back to work" 433 | 434 | ( Work _, LongBreak _ ) -> 435 | "Time to relax" 436 | 437 | ( LongBreak _, Work _ ) -> 438 | "What is next?" 439 | 440 | _ -> 441 | "" 442 | 443 | 444 | calculateSentiment : List Round -> Sentiment 445 | calculateSentiment = 446 | List.filter (.type_ >> isWork) 447 | >> List.map (.sentiment >> Maybe.withDefault Neutral) 448 | >> List.foldl 449 | (\sentiment ( pos, neu, neg ) -> 450 | case sentiment of 451 | Positive -> 452 | ( pos + 1, neu, neg ) 453 | 454 | Neutral -> 455 | ( pos, neu + 1, neg ) 456 | 457 | Negative -> 458 | ( pos, neu, neg + 1 ) 459 | ) 460 | ( 0, 0, 0 ) 461 | >> (\( pos, neu, neg ) -> 462 | if neg >= neu && neg >= pos then 463 | Negative 464 | 465 | else if neu >= pos && neu >= neg then 466 | Neutral 467 | 468 | else 469 | Positive 470 | ) 471 | 472 | 473 | sentimentToIcon : Sentiment -> String 474 | sentimentToIcon sentiment = 475 | case sentiment of 476 | Positive -> 477 | "sentiment_satisfied" 478 | 479 | Neutral -> 480 | "sentiment_neutral" 481 | 482 | Negative -> 483 | "sentiment_dissatisfied" 484 | -------------------------------------------------------------------------------- /src/js/spotify.ts: -------------------------------------------------------------------------------- 1 | import randomString from "crypto-random-string"; 2 | import pkceChallenge from "pkce-challenge"; 3 | import { DecoderFunction } from "typescript-json-decoder"; 4 | import { ElmApp, SpotifyDef, ToSpotifyPayload } from "../globals"; 5 | import { 6 | AuthData, 7 | authData as authDataDecoder, 8 | decodeWith, 9 | PlaybackState, 10 | playbackState, 11 | spotifyApiToken, 12 | SpotifyApiToken, 13 | SpotifyConnectData, 14 | spotifyConnectData, 15 | spotifyPlaylist, 16 | } from "./decoders"; 17 | import setFlash from "./helpers/flash"; 18 | import * as storage from "./helpers/local-storage"; 19 | import { Result, resultCallback, resultErr, resultMap } from "./result"; 20 | 21 | const clientId = import.meta.env.VITE_SPOTIFY_CLIENT_ID; 22 | const redirectUri = import.meta.env.VITE_SPOTIFY_REDIRECT_URL; 23 | const redirectUrl = new URL(redirectUri); 24 | 25 | let player: Spotify.Player | undefined; 26 | 27 | interface Playlist { 28 | uri: string; 29 | title: string; 30 | } 31 | 32 | const spotify: SpotifyDef = { 33 | connected: false, 34 | canPlay: false, 35 | playing: false, 36 | deviceId: null, 37 | }; 38 | 39 | window.spotify = spotify; 40 | 41 | async function fetchAsJsonResult( 42 | url: string, 43 | init: RequestInit, 44 | decoder: DecoderFunction 45 | ): Promise> { 46 | try { 47 | const response = await fetch(url, init); 48 | const json: unknown = await response.json(); 49 | 50 | return decodeWith(decoder, json); 51 | } catch (e) { 52 | return resultErr( 53 | new Error( 54 | `Could not process request to: ${url} | ERROR: ${JSON.stringify(e)}` 55 | ) 56 | ); 57 | } 58 | } 59 | 60 | const connectData = (): SpotifyConnectData => { 61 | const pkce = pkceChallenge(128); 62 | const state = randomString({ length: 16, type: "url-safe" }); 63 | 64 | // https://developer.spotify.com/documentation/general/guides/scopes/ 65 | const scopes = [ 66 | // Spotify Connect 67 | "user-read-playback-state", 68 | "user-modify-playback-state", 69 | "user-read-currently-playing", 70 | 71 | // Playback 72 | "app-remote-control", 73 | "streaming", 74 | 75 | // Playlists 76 | "playlist-read-private", 77 | "playlist-read-collaborative", 78 | 79 | // Users 80 | "user-read-email", 81 | "user-read-private", 82 | ]; 83 | 84 | const url = 85 | `https://accounts.spotify.com/authorize?client_id=${clientId}` + 86 | `&response_type=code` + 87 | `&redirect_uri=${encodeURI(redirectUri)}` + 88 | `&state=${state}` + 89 | `&code_challenge_method=S256` + 90 | `&code_challenge=${pkce.code_challenge}` + 91 | `&scope=${scopes.join(",")}`; 92 | 93 | const data = { ...pkce, state, url }; 94 | 95 | storage.set("spotifyConnectData", data); 96 | 97 | return data; 98 | }; 99 | 100 | const processAuthData = (apiTokeData: SpotifyApiToken): AuthData => { 101 | const now = Date.now(); 102 | const expiresAt = now + apiTokeData.expires_in * 1000; 103 | const authData: AuthData = { ...apiTokeData, expires_at: expiresAt }; 104 | 105 | storage.set("spotifyAuthData", authData); 106 | 107 | return authData; 108 | }; 109 | 110 | const getPlaylists = async (token: string): Promise> => { 111 | const result = await fetchAsJsonResult( 112 | "https://api.spotify.com/v1/me/playlists?limit=50", 113 | { headers: { Authorization: `Bearer ${token}` } }, 114 | spotifyPlaylist 115 | ); 116 | 117 | return resultMap(result, (data) => 118 | data.items.map((item) => ({ 119 | uri: item.uri, 120 | title: item.name, 121 | })) 122 | ); 123 | }; 124 | 125 | const authRequest = async ( 126 | body: URLSearchParams 127 | ): Promise> => { 128 | const result = await fetchAsJsonResult( 129 | "https://accounts.spotify.com/api/token", 130 | { method: "POST", body }, 131 | spotifyApiToken 132 | ); 133 | 134 | return resultMap(result, (data) => { 135 | window.spotify.connected = true; 136 | 137 | return processAuthData(data); 138 | }); 139 | }; 140 | 141 | const refreshAuthToken = async (token: string): Promise> => { 142 | const body = new URLSearchParams(); 143 | 144 | body.append("client_id", clientId); 145 | body.append("grant_type", "refresh_token"); 146 | body.append("refresh_token", token); 147 | 148 | return authRequest(body); 149 | }; 150 | 151 | const getAuthToken = ( 152 | code: string, 153 | state: string 154 | ): Promise> => { 155 | const result = decodeWith( 156 | spotifyConnectData, 157 | storage.get("spotifyConnectData") 158 | ); 159 | 160 | if (result.status === "err") { 161 | return Promise.resolve(result); 162 | } 163 | 164 | const connectData = result.data; 165 | 166 | if (state != connectData.state) { 167 | return Promise.resolve(resultErr(new Error("Invalid `state` value."))); 168 | } 169 | 170 | const body = new URLSearchParams(); 171 | 172 | body.append("client_id", clientId); 173 | body.append("grant_type", "authorization_code"); 174 | body.append("code", code); 175 | body.append("redirect_uri", redirectUri); 176 | body.append("code_verifier", connectData.code_verifier); 177 | 178 | return authRequest(body); 179 | }; 180 | 181 | const notConnected = (app: ElmApp, flash = true): void => { 182 | app.ports.gotFromSpotify.send({ 183 | type: "notconnected", 184 | url: connectData().url, 185 | }); 186 | 187 | if (flash) { 188 | setFlash(app, "Your Spotify account is disconnected."); 189 | } 190 | }; 191 | 192 | const connectionError = (app: ElmApp): void => { 193 | app.ports.gotFromSpotify.send({ 194 | type: "connectionerror", 195 | url: connectData().url, 196 | }); 197 | 198 | setFlash( 199 | app, 200 | "There was an error trying to connect to your Spotify account. Note: you need a Premium account to connect." 201 | ); 202 | }; 203 | 204 | const connected = (app: ElmApp, playlists: Playlist[], flash = true): void => { 205 | app.ports.gotFromSpotify.send({ 206 | type: "connected", 207 | playlists: playlists, 208 | playlist: null, 209 | }); 210 | 211 | if (flash) { 212 | setFlash(app, "Your Spotify account is connected."); 213 | } 214 | }; 215 | 216 | const setupPlaylists = (app: ElmApp, token: string, flash: boolean): void => { 217 | void getPlaylists(token).then((result) => { 218 | resultCallback( 219 | result, 220 | (playlists) => connected(app, playlists, flash), 221 | () => notConnected(app, flash) 222 | ); 223 | }); 224 | }; 225 | 226 | const connectionCallback = (app: ElmApp, code: string, state: string): void => { 227 | void getAuthToken(code, state).then((result) => { 228 | resultCallback( 229 | result, 230 | (data) => init(app, data.access_token, true), 231 | () => connectionError(app) 232 | ); 233 | }); 234 | 235 | history.pushState({}, "", redirectUrl.pathname); 236 | }; 237 | 238 | const initPlayer = (app: ElmApp, token: string, retries: number): void => { 239 | if (retries > 9) { 240 | return; 241 | } 242 | 243 | if (!window.spotifyPlayerLoaded || !window.spotify.connected) { 244 | setTimeout(() => initPlayer(app, token, retries + 1), 1000); 245 | return; 246 | } 247 | 248 | player = new Spotify.Player({ 249 | name: "Pelmodoro", 250 | getOAuthToken: (cb) => cb(token), 251 | volume: 1, 252 | }); 253 | 254 | player.addListener("ready", ({ device_id }) => { 255 | window.spotify.canPlay = true; 256 | window.spotify.deviceId = device_id; 257 | }); 258 | 259 | player.addListener("not_ready", ({ device_id }) => { 260 | window.spotify.canPlay = false; 261 | window.spotify.deviceId = device_id; 262 | }); 263 | 264 | player.addListener("authentication_error", () => notConnected(app)); 265 | player.addListener("account_error", () => connectionError(app)); 266 | 267 | void player.connect(); 268 | }; 269 | 270 | const initApp = (app: ElmApp, flash = true): void => { 271 | const result = decodeWith(authDataDecoder, storage.get("spotifyAuthData")); 272 | 273 | const okCallback = async (authData: AuthData) => { 274 | const now = Date.now(); 275 | 276 | if (now > authData.expires_at) { 277 | const refreshResult = await refreshAuthToken(authData.refresh_token); 278 | 279 | resultCallback( 280 | refreshResult, 281 | (data) => { 282 | storage.set("spotifyAuthData", data); 283 | init(app, data.access_token, flash); 284 | }, 285 | () => notConnected(app) 286 | ); 287 | } else { 288 | const expiresDiff = authData.expires_at - now; 289 | const timeout = Math.floor(expiresDiff) + 1000; 290 | 291 | setTimeout(() => initApp(app), timeout); 292 | 293 | window.spotify.connected = true; 294 | 295 | init(app, authData.access_token, flash); 296 | } 297 | }; 298 | 299 | const errCallback = () => notConnected(app, flash); 300 | 301 | resultCallback(result, okCallback, errCallback); 302 | }; 303 | 304 | interface ReqParams { 305 | method: "PUT"; 306 | headers: Record; 307 | } 308 | 309 | const apiReqParams = (token: string): ReqParams => { 310 | return { 311 | method: "PUT", 312 | headers: { 313 | "Content-Type": "application/json", 314 | Authorization: `Bearer ${token}`, 315 | }, 316 | }; 317 | }; 318 | 319 | const pause = (token: string): void => { 320 | if (!window.spotify.playing) return; 321 | 322 | void checkStateReq(token).then((result) => { 323 | resultCallback(result, (state) => storage.set("spotifyLastState", state)); 324 | }); 325 | 326 | const deviceId = window.spotify.deviceId ?? ""; 327 | 328 | void fetch( 329 | `https://api.spotify.com/v1/me/player/pause?device_id=${deviceId}`, 330 | apiReqParams(token) 331 | ).then(() => (window.spotify.playing = false)); 332 | }; 333 | 334 | interface PlayRequestBody { 335 | context_uri: string; 336 | position_ms?: number | null; 337 | offset?: { 338 | uri: string; 339 | }; 340 | } 341 | 342 | const play = (token: string, uri: string): void => { 343 | if (!window.spotify.canPlay) return; 344 | 345 | let body: PlayRequestBody = { context_uri: uri }; 346 | 347 | const lastStateResult = decodeWith( 348 | playbackState, 349 | storage.get("spotifyLastState") 350 | ); 351 | 352 | if ( 353 | lastStateResult.status === "ok" && 354 | lastStateResult.data.context.uri === uri 355 | ) { 356 | const { 357 | item: { uri }, 358 | progress_ms, 359 | } = lastStateResult.data; 360 | 361 | body = { 362 | ...body, 363 | position_ms: progress_ms, 364 | offset: { uri }, 365 | }; 366 | } 367 | 368 | const deviceId = window.spotify.deviceId ?? ""; 369 | 370 | void fetch( 371 | `https://api.spotify.com/v1/me/player/play?device_id=${deviceId}`, 372 | { 373 | ...apiReqParams(token), 374 | body: JSON.stringify(body), 375 | } 376 | ).then((res) => { 377 | if (![202, 204].includes(res.status)) { 378 | window.spotify.playing = false; 379 | } else { 380 | window.spotify.playing = true; 381 | } 382 | }); 383 | }; 384 | 385 | const checkStateReq = (token: string): Promise> => { 386 | return fetchAsJsonResult( 387 | "https://api.spotify.com/v1/me/player", 388 | { 389 | headers: { 390 | "Content-Type": "application/json", 391 | Authorization: `Bearer ${token}`, 392 | }, 393 | }, 394 | playbackState 395 | ); 396 | }; 397 | 398 | const checkState = (token: string): void => { 399 | setInterval(() => { 400 | if (!window.spotify.playing) return; 401 | 402 | void checkStateReq(token).then((result) => 403 | resultCallback(result, (state) => storage.set("spotifyLastState", state)) 404 | ); 405 | }, 30 * 1000); 406 | }; 407 | 408 | const disconnect = (app: ElmApp): void => { 409 | storage.del("spotifyLastState"); 410 | storage.del("spotifyAuthData"); 411 | storage.del("spotifyConnectData"); 412 | 413 | window.spotify.connected = false; 414 | window.spotify.canPlay = false; 415 | window.spotify.playing = false; 416 | window.spotify.deviceId = null; 417 | 418 | player?.disconnect(); 419 | notConnected(app); 420 | }; 421 | 422 | const init = (app: ElmApp, token: string, flash: boolean): void => { 423 | initPlayer(app, token, 0); 424 | setupPlaylists(app, token, flash); 425 | checkState(token); 426 | 427 | app.ports.toSpotify.subscribe((data: ToSpotifyPayload) => { 428 | switch (data.type) { 429 | case "play": 430 | play(token, data.url); 431 | break; 432 | 433 | case "pause": 434 | pause(token); 435 | break; 436 | 437 | case "refresh": 438 | setupPlaylists(app, token, true); 439 | break; 440 | 441 | case "disconnect": 442 | disconnect(app); 443 | break; 444 | } 445 | }); 446 | }; 447 | 448 | export default function(app: ElmApp): void { 449 | const query = new URLSearchParams(window.location.search); 450 | const code = query.get("code"); 451 | const state = query.get("state"); 452 | 453 | if (window.location.pathname == redirectUrl.pathname && code && state) { 454 | connectionCallback(app, code, state); 455 | } else { 456 | initApp(app, false); 457 | } 458 | } 459 | -------------------------------------------------------------------------------- /src/Page/Settings.elm: -------------------------------------------------------------------------------- 1 | module Page.Settings exposing 2 | ( Model 3 | , Msg 4 | , subscriptions 5 | , update 6 | , view 7 | ) 8 | 9 | import Css 10 | import Elements 11 | import File 12 | import File.Select as Select 13 | import Html.Styled as Html 14 | import Html.Styled.Attributes as Attributes 15 | import Json.Decode as Decode 16 | import Json.Encode as Encode 17 | import Misc 18 | import Page.Flash as Flash 19 | import Page.MiniTimer as MiniTimer 20 | import Page.Spotify as Spotify 21 | import Ports 22 | import Session 23 | import Settings 24 | import Task 25 | import Theme 26 | import Tuple.Trio as Trio 27 | 28 | 29 | 30 | -- MODEL 31 | 32 | 33 | type alias Model a = 34 | { a 35 | | settings : Settings.Settings 36 | , flash : Maybe Flash.FlashMsg 37 | , active : Session.ActiveRound 38 | , sessions : List Session.RoundType 39 | , playing : Bool 40 | } 41 | 42 | 43 | 44 | -- VIEW 45 | 46 | 47 | view : Model a -> Html.Html Msg 48 | view ({ settings } as model) = 49 | let 50 | inMinutes : Int -> Int 51 | inMinutes seconds = 52 | seconds // 60 53 | in 54 | Html.div [] 55 | [ MiniTimer.view model 56 | , Html.div 57 | [ Attributes.css 58 | [ Css.margin2 (Css.rem 2) Css.auto 59 | , Css.width <| Css.px 280 60 | ] 61 | ] 62 | [ Elements.h1 settings.theme "Settings" 63 | , Elements.inputContainer "Rounds" <| 64 | Elements.numberInput settings.theme 1 8 UpdateRounds settings.rounds 65 | , Elements.inputContainer "Session duration" <| 66 | Elements.numberInput settings.theme 1 60 UpdateWorkDuration <| 67 | inMinutes settings.workDuration 68 | , Elements.inputContainer "Break duration" <| 69 | Elements.numberInput settings.theme 1 60 UpdateBreakDuration <| 70 | inMinutes settings.breakDuration 71 | , Elements.inputContainer "Long break duration" <| 72 | Elements.numberInput settings.theme 1 60 UpdateLongBreakDuration <| 73 | inMinutes settings.longBreakDuration 74 | , Elements.inputContainer "Rounds flow" <| 75 | Elements.selectInput settings.theme 76 | (Trio.first >> (==) settings.flow) 77 | UpdateFlow 78 | Settings.flowTypeAndStrings 79 | , Elements.inputContainer "Notifications" 80 | ([ ( settings.notifications.inApp, Settings.InApp, "In app messages" ) 81 | , ( settings.notifications.alarmSound, Settings.AlarmSound, "Play sounds" ) 82 | , ( settings.notifications.browser, Settings.Browser, "Browser notification" ) 83 | ] 84 | |> List.map (\( v, t, l ) -> Elements.checkbox settings.theme v (ToggleNotification t) l) 85 | |> Html.div [] 86 | ) 87 | , if settings.notifications.alarmSound then 88 | Elements.inputContainer "Alarm sound" <| 89 | Html.div [] 90 | [ Elements.selectInput settings.theme 91 | (Trio.first >> (==) settings.alarmSound) 92 | UpdateAlarmSound 93 | Settings.alarmSoundTypeAndStrings 94 | |> Elements.simpleSeparator 95 | , Elements.largeButton settings.theme 96 | (TestAlarmSound settings.alarmSound) 97 | [ Elements.styledIcon [ Css.verticalAlign Css.middle ] "play_arrow" ] 98 | ] 99 | 100 | else 101 | Html.text "" 102 | , Elements.inputContainer "Color theme" <| 103 | Elements.selectInput settings.theme 104 | (Trio.first >> (==) settings.theme) 105 | UpdateTheme 106 | Theme.themeTypeAndStrings 107 | , Spotify.view settings.theme settings.spotify |> Html.map Spotify 108 | , Elements.inputContainer "Import / Export" <| 109 | Html.div [] 110 | [ Elements.largeButton settings.theme ExportRequest [ Html.text "Export" ] 111 | |> Elements.simpleSeparator 112 | , Elements.largeButton settings.theme ImportRequest [ Html.text "Import" ] 113 | |> Elements.simpleSeparator 114 | , Elements.largeButton settings.theme ClearLogs [ Html.text "Clear logs" ] 115 | ] 116 | ] 117 | ] 118 | 119 | 120 | 121 | -- UPDATE 122 | 123 | 124 | type Msg 125 | = UpdateRounds Int 126 | | UpdateWorkDuration Int 127 | | UpdateBreakDuration Int 128 | | UpdateLongBreakDuration Int 129 | | UpdateFlow String 130 | | UpdateTheme String 131 | | UpdateAlarmSound String 132 | | ToggleNotification Settings.NotificationType 133 | | GotBrowserNotificationPermission Decode.Value 134 | | ExportRequest 135 | | ImportRequest 136 | | ImportSelect File.File 137 | | ClearLogs 138 | | ImportData String 139 | | TestAlarmSound Settings.AlarmSound 140 | | Spotify Spotify.Msg 141 | 142 | 143 | update : Msg -> Model a -> ( Model a, Cmd Msg ) 144 | update msg ({ settings } as model) = 145 | case msg of 146 | UpdateRounds rounds -> 147 | model 148 | |> mapSettings (\s -> { s | rounds = rounds }) 149 | |> Misc.withCmd 150 | |> save 151 | 152 | UpdateWorkDuration secs -> 153 | model 154 | |> mapSettings (\s -> { s | workDuration = secs * 60 }) 155 | |> Misc.withCmd 156 | |> save 157 | 158 | UpdateBreakDuration secs -> 159 | model 160 | |> mapSettings (\s -> { s | breakDuration = secs * 60 }) 161 | |> Misc.withCmd 162 | |> save 163 | 164 | UpdateLongBreakDuration secs -> 165 | model 166 | |> mapSettings (\s -> { s | longBreakDuration = secs * 60 }) 167 | |> Misc.withCmd 168 | |> save 169 | 170 | UpdateFlow flow -> 171 | model 172 | |> mapSettings 173 | (\s -> 174 | flow 175 | |> Misc.encodableToType Settings.flowTypeAndStrings 176 | |> Maybe.map (\f -> { s | flow = f }) 177 | |> Maybe.withDefault s 178 | ) 179 | |> Misc.withCmd 180 | |> save 181 | 182 | UpdateTheme theme -> 183 | model 184 | |> mapSettings 185 | (\s -> 186 | theme 187 | |> Misc.encodableToType Theme.themeTypeAndStrings 188 | |> Maybe.map (\t -> { s | theme = t }) 189 | |> Maybe.withDefault s 190 | ) 191 | |> Misc.withCmd 192 | |> save 193 | 194 | UpdateAlarmSound alarm -> 195 | model 196 | |> mapSettings 197 | (\s -> 198 | alarm 199 | |> Misc.encodableToType Settings.alarmSoundTypeAndStrings 200 | |> Maybe.map (\a -> { s | alarmSound = a }) 201 | |> Maybe.withDefault s 202 | ) 203 | |> Misc.withCmd 204 | |> save 205 | 206 | ToggleNotification type_ -> 207 | case type_ of 208 | Settings.Browser -> 209 | model 210 | |> Misc.withCmd 211 | |> Misc.addCmd 212 | (settings.notifications.browser 213 | |> not 214 | |> RequestBrowserPermission 215 | |> toPort 216 | ) 217 | 218 | _ -> 219 | model 220 | |> mapSettings (\s -> { s | notifications = Settings.toggleNotification type_ s.notifications }) 221 | |> Misc.withCmd 222 | |> save 223 | 224 | GotBrowserNotificationPermission raw -> 225 | case Decode.decodeValue Settings.decodeBrowserNotificationPermission raw of 226 | Ok res -> 227 | let 228 | notifications : Settings.Notifications 229 | notifications = 230 | settings.notifications 231 | 232 | newNotifications : Settings.Notifications 233 | newNotifications = 234 | { notifications | browser = res.val } 235 | 236 | flashMsg : Maybe Flash.FlashMsg 237 | flashMsg = 238 | if res.msg /= "" then 239 | Flash.new res.msg |> Just 240 | 241 | else 242 | Nothing 243 | in 244 | model 245 | |> mapSettings (\s -> { s | notifications = newNotifications }) 246 | |> Flash.setFlash flashMsg 247 | |> Misc.withCmd 248 | |> save 249 | 250 | Err _ -> 251 | Misc.withCmd model 252 | 253 | ExportRequest -> 254 | model 255 | |> Misc.withCmd 256 | |> Misc.addCmd (RequestExport |> toPort) 257 | 258 | ImportRequest -> 259 | model 260 | |> Misc.withCmd 261 | |> Misc.addCmd (Select.file [ "application/json" ] ImportSelect) 262 | 263 | ImportSelect file -> 264 | model 265 | |> Misc.withCmd 266 | |> Misc.addCmd (Task.perform ImportData (File.toString file)) 267 | 268 | ClearLogs -> 269 | model 270 | |> Misc.withCmd 271 | |> Misc.addCmd (Delete |> toPort) 272 | 273 | ImportData data -> 274 | model 275 | |> Misc.withCmd 276 | |> Misc.addCmd (Import data |> toPort) 277 | 278 | TestAlarmSound alarmSound -> 279 | model 280 | |> Misc.withCmd 281 | |> Misc.addCmd 282 | (alarmSound 283 | |> Misc.typeToEncodable Settings.alarmSoundTypeAndStrings 284 | |> Maybe.withDefault "" 285 | |> TestAlarm 286 | |> toPort 287 | ) 288 | 289 | Spotify subMsg -> 290 | Spotify.update subMsg settings.spotify 291 | |> Tuple.mapFirst (\state -> model |> mapSettings (\s -> { s | spotify = state })) 292 | |> Misc.updateWith Spotify 293 | |> save 294 | 295 | 296 | 297 | -- HELPERS 298 | 299 | 300 | mapSettings : (Settings.Settings -> Settings.Settings) -> Model a -> Model a 301 | mapSettings fn model = 302 | { model | settings = fn model.settings } 303 | 304 | 305 | save : ( Model a, Cmd Msg ) -> ( Model a, Cmd Msg ) 306 | save ( model, cmd ) = 307 | let 308 | ( newRounds, newActive ) = 309 | Session.buildRounds model.settings (Just model.active) 310 | in 311 | { model | playing = False, sessions = newRounds, active = newActive } 312 | |> Misc.withCmd 313 | |> Misc.addCmd cmd 314 | |> Misc.addCmd 315 | (Cmd.batch 316 | [ model.settings |> Settings.encodeSettings |> Ports.localStorageHelper "settings" 317 | , newActive |> Session.encodeActiveRound |> Ports.localStorageHelper "active" 318 | , Spotify.pause model.settings.spotify 319 | ] 320 | ) 321 | 322 | 323 | 324 | -- PORTS INTERFACE 325 | 326 | 327 | type PortAction 328 | = RequestExport 329 | | Import String 330 | | Delete 331 | | TestAlarm String 332 | | RequestBrowserPermission Bool 333 | 334 | 335 | encodePortAction : PortAction -> Encode.Value 336 | encodePortAction actions = 337 | case actions of 338 | RequestExport -> 339 | Encode.object [ ( "type", Encode.string "requestExport" ) ] 340 | 341 | Import data -> 342 | Encode.object 343 | [ ( "type", Encode.string "import" ) 344 | , ( "data", Encode.string data ) 345 | ] 346 | 347 | Delete -> 348 | Encode.object [ ( "type", Encode.string "delete" ) ] 349 | 350 | TestAlarm sound -> 351 | Encode.object 352 | [ ( "type", Encode.string "testAlarm" ) 353 | , ( "data", Encode.string sound ) 354 | ] 355 | 356 | RequestBrowserPermission val -> 357 | Encode.object 358 | [ ( "type", Encode.string "browserPermission" ) 359 | , ( "data", Encode.bool val ) 360 | ] 361 | 362 | 363 | toPort : PortAction -> Cmd msg 364 | toPort = 365 | encodePortAction >> Ports.toSettings 366 | 367 | 368 | 369 | -- SUBSCRIPTIONS 370 | 371 | 372 | subscriptions : Sub Msg 373 | subscriptions = 374 | Sub.batch 375 | [ Ports.gotFromSettings GotBrowserNotificationPermission 376 | , Spotify.subscriptions |> Sub.map Spotify 377 | ] 378 | -------------------------------------------------------------------------------- /src/Page/Timer.elm: -------------------------------------------------------------------------------- 1 | module Page.Timer exposing (Model, Msg, secondsToDisplay, subscriptions, update, view) 2 | 3 | import Color 4 | import Css 5 | import Elements 6 | import Html.Styled as Html 7 | import Html.Styled.Attributes as Attributes 8 | import Html.Styled.Events as Events 9 | import Json.Decode as Decode 10 | import Json.Encode as Encode 11 | import List.Extra 12 | import Misc 13 | import Page.Flash as Flash 14 | import Page.Spotify as Spotify 15 | import Page.Stats as Stats 16 | import Ports 17 | import Session 18 | import Settings 19 | import Svg.Styled as Svg 20 | import Svg.Styled.Attributes as SvgAttributes 21 | import Theme 22 | import Theme.Common 23 | import Time 24 | import Tuple.Trio as Trio 25 | 26 | 27 | 28 | -- MODEL 29 | 30 | 31 | type alias Model a = 32 | { a 33 | | time : Time.Posix 34 | , playing : Bool 35 | , active : Session.ActiveRound 36 | , settings : Settings.Settings 37 | , sessions : List Session.RoundType 38 | , uptime : Int 39 | , flash : Maybe Flash.FlashMsg 40 | , sentimentSession : Maybe Session.Round 41 | } 42 | 43 | 44 | type alias EvalResult msg = 45 | { active : Session.ActiveRound 46 | , playing : Bool 47 | , flash : Maybe Flash.FlashMsg 48 | , cmd : Cmd msg 49 | , sentimentSession : Maybe Session.Round 50 | } 51 | 52 | 53 | 54 | -- VIEW 55 | 56 | 57 | view : Model a -> Html.Html Msg 58 | view model = 59 | let 60 | svgBaseSize : Int 61 | svgBaseSize = 62 | 280 63 | 64 | toViewBox : Int -> String 65 | toViewBox = 66 | List.repeat 2 >> List.map String.fromInt >> String.join " " >> (++) "0 0 " 67 | in 68 | Html.div [ Attributes.css [ Css.width <| Css.pct 100, Css.height <| Css.pct 100 ] ] 69 | [ Html.div 70 | [ Attributes.css 71 | [ Css.displayFlex 72 | , Css.flexDirection Css.column 73 | , Css.alignItems Css.center 74 | , Css.justifyContent Css.center 75 | , Css.width <| Css.pct 100.0 76 | , Css.height <| Css.pct 100.0 77 | ] 78 | ] 79 | [ Svg.svg 80 | [ SvgAttributes.width <| String.fromInt svgBaseSize 81 | , SvgAttributes.height <| String.fromInt svgBaseSize 82 | , SvgAttributes.viewBox <| toViewBox svgBaseSize 83 | ] 84 | (viewSessionsArcs svgBaseSize model.settings.theme model.active model.sessions 85 | ++ [ viewTimer model.playing model.uptime model.settings.theme model.active ] 86 | ) 87 | , viewControls model.settings.theme model.playing 88 | ] 89 | , viewSentimentQuery model.settings.theme model.sentimentSession 90 | ] 91 | 92 | 93 | viewSessionsArcs : Int -> Theme.Common.Theme -> Session.ActiveRound -> List Session.RoundType -> List (Svg.Svg msg) 94 | viewSessionsArcs size theme active rounds = 95 | let 96 | totalRun : Float 97 | totalRun = 98 | rounds |> Session.roundsTotalRun |> toFloat 99 | 100 | strokeWidth : Int 101 | strokeWidth = 102 | 8 103 | 104 | centerPoint : Float 105 | centerPoint = 106 | toFloat size / 2 107 | 108 | radius : Float 109 | radius = 110 | centerPoint - (toFloat strokeWidth / 2) 111 | 112 | arcsOffset : Float 113 | arcsOffset = 114 | 3.0 115 | in 116 | rounds 117 | |> List.foldl 118 | (\round ( paths, idx, startAngle ) -> 119 | let 120 | roundSecs : Float 121 | roundSecs = 122 | round |> Session.roundSeconds |> toFloat 123 | 124 | roundAngle : Float 125 | roundAngle = 126 | 360.0 * roundSecs / totalRun 127 | 128 | endAngle : Float 129 | endAngle = 130 | startAngle + roundAngle 131 | 132 | buildArc : Session.RoundType -> String -> Float -> Float -> Svg.Svg msg 133 | buildArc round_ opacity_ start_ end_ = 134 | Svg.path 135 | [ SvgAttributes.strokeWidth <| String.fromInt strokeWidth 136 | , SvgAttributes.strokeLinecap "round" 137 | , SvgAttributes.fill "none" 138 | , SvgAttributes.stroke (round_ |> Session.roundToColor theme |> Color.toRgbaString) 139 | , SvgAttributes.d (describeArc centerPoint centerPoint radius start_ end_) 140 | , SvgAttributes.opacity opacity_ 141 | ] 142 | [] 143 | 144 | activeArc : Svg.Svg msg 145 | activeArc = 146 | if idx == active.index then 147 | let 148 | elapsedPct : Float 149 | elapsedPct = 150 | Session.elapsedPct active 151 | 152 | elapsedIntervalAngle : Float 153 | elapsedIntervalAngle = 154 | (roundAngle - arcsOffset * 2) * elapsedPct / 100.0 155 | 156 | startAngle_ : Float 157 | startAngle_ = 158 | startAngle + arcsOffset 159 | 160 | endAngle_ : Float 161 | endAngle_ = 162 | startAngle_ + elapsedIntervalAngle 163 | in 164 | buildArc round "1" startAngle_ endAngle_ 165 | 166 | else 167 | Svg.path [] [] 168 | 169 | opacity : String 170 | opacity = 171 | if idx >= active.index then 172 | ".35" 173 | 174 | else 175 | "1" 176 | in 177 | ( buildArc round opacity (startAngle + arcsOffset) (endAngle - arcsOffset) :: activeArc :: paths 178 | , idx + 1 179 | , endAngle 180 | ) 181 | ) 182 | ( [], 0, 0 ) 183 | |> Trio.first 184 | 185 | 186 | viewTimer : Bool -> Int -> Theme.Common.Theme -> Session.ActiveRound -> Svg.Svg Msg 187 | viewTimer playing uptime theme active = 188 | let 189 | timerOpacity : String 190 | timerOpacity = 191 | if playing then 192 | "100" 193 | 194 | else if (uptime |> modBy 2) == 0 then 195 | "100" 196 | 197 | else 198 | "0" 199 | in 200 | Svg.text_ 201 | [ SvgAttributes.x "50%" 202 | , SvgAttributes.y "55%" 203 | , SvgAttributes.textAnchor "middle" 204 | , SvgAttributes.fill (active.round.type_ |> Session.roundToColor theme |> Color.toRgbaString) 205 | , SvgAttributes.fontFamily "Montserrat" 206 | , SvgAttributes.fontSize "36px" 207 | , SvgAttributes.opacity timerOpacity 208 | ] 209 | [ Svg.text <| secondsToDisplay (Session.secondsLeft active |> truncate) ] 210 | 211 | 212 | viewControls : Theme.Common.Theme -> Bool -> Html.Html Msg 213 | viewControls theme playing = 214 | let 215 | buttonStyle : Css.Style 216 | buttonStyle = 217 | Css.batch 218 | [ Css.borderStyle Css.none 219 | , Css.backgroundColor Css.transparent 220 | , Css.width <| Css.rem 3 221 | , Css.height <| Css.rem 3 222 | , Css.color (theme |> Theme.foregroundColor |> Color.toCssColor) 223 | , Css.outline Css.zero 224 | , Css.cursor Css.pointer 225 | ] 226 | 227 | button : String -> msg -> Html.Html msg 228 | button icon msg = 229 | Html.button 230 | [ Events.onClick msg, Attributes.css [ buttonStyle ] ] 231 | [ Elements.icon icon ] 232 | in 233 | Html.ul 234 | [ Attributes.css [ Css.listStyle Css.none, Css.displayFlex, Css.marginTop <| Css.rem 1.0 ] ] 235 | [ Html.li [] 236 | [ if playing then 237 | button "pause" Pause 238 | 239 | else 240 | button "play_arrow" Play 241 | ] 242 | , Html.li [] [ button "skip_next" Skip ] 243 | , Html.li [] [ button "restart_alt" Reset ] 244 | ] 245 | 246 | 247 | viewSentimentQuery : Theme.Common.Theme -> Maybe Session.Round -> Html.Html Msg 248 | viewSentimentQuery theme round = 249 | round 250 | |> Maybe.andThen .start 251 | |> Maybe.map 252 | (\start -> 253 | Html.div 254 | [ Attributes.css 255 | [ Css.position Css.absolute 256 | , Css.bottom <| Css.rem 5 257 | , Css.left Css.zero 258 | , Css.right Css.zero 259 | ] 260 | ] 261 | [ Html.div 262 | [ Attributes.css 263 | [ Css.maxWidth <| Css.rem 17.5 264 | , Css.width <| Css.pct 100 265 | , Css.margin2 Css.zero Css.auto 266 | ] 267 | ] 268 | [ Html.div 269 | [ Attributes.css 270 | [ Css.color (theme |> Theme.textColor |> Color.toCssColor) 271 | , Css.fontSize <| Css.rem 0.75 272 | , Css.marginBottom <| Css.rem 1 273 | , Css.textAlign Css.center 274 | ] 275 | ] 276 | [ Html.strong 277 | [] 278 | [ Html.text "What is your feeling about the last working session?" ] 279 | , Html.text " You can set this later on the stats area." 280 | ] 281 | , Html.ul 282 | [ Attributes.css 283 | [ Css.displayFlex 284 | , Css.justifyContent Css.spaceAround 285 | , Css.fontSize <| Css.rem 2 286 | , Css.width <| Css.pct 100 287 | , Css.listStyle Css.none 288 | ] 289 | ] 290 | ([ ( "Positive", SetSentiment start Session.positive, "sentiment_satisfied" ) 291 | , ( "Neutral", SetSentiment start Session.neutral, "sentiment_neutral" ) 292 | , ( "Negative", SetSentiment start Session.negative, "sentiment_dissatisfied" ) 293 | ] 294 | |> List.map 295 | (\( label, msg, icon ) -> 296 | Html.li [] 297 | [ Html.button 298 | [ Events.onClick msg 299 | , Attributes.title label 300 | , Attributes.css 301 | [ Css.backgroundColor Css.transparent 302 | , Css.border Css.zero 303 | , Css.padding Css.zero 304 | , Css.margin Css.zero 305 | , Css.cursor Css.pointer 306 | ] 307 | ] 308 | [ Elements.styledIcon 309 | [ Css.fontSize <| Css.rem 3 310 | , Css.color (theme |> Theme.textColor |> Color.toCssColor) 311 | ] 312 | icon 313 | ] 314 | ] 315 | ) 316 | ) 317 | ] 318 | ] 319 | ) 320 | |> Maybe.withDefault (Html.text "") 321 | 322 | 323 | 324 | -- UPDATE 325 | 326 | 327 | type Msg 328 | = Tick Decode.Value 329 | | Play 330 | | Pause 331 | | Skip 332 | | Reset 333 | | SetSentiment Time.Posix Session.Sentiment 334 | 335 | 336 | update : Msg -> Model a -> ( Model a, Cmd msg ) 337 | update msg ({ settings, active, time, sessions } as model) = 338 | case msg of 339 | Tick raw -> 340 | case Decode.decodeValue Decode.int raw of 341 | Ok millis -> 342 | model |> tick (Time.millisToPosix millis) 343 | 344 | Err _ -> 345 | Misc.withCmd model 346 | 347 | Play -> 348 | let 349 | newActive : Session.ActiveRound 350 | newActive = 351 | if active.elapsed == 0 then 352 | Session.ActiveRound active.index (Session.roundStart time active.round) 0 353 | 354 | else 355 | active 356 | 357 | cmds : Cmd msg 358 | cmds = 359 | Cmd.batch 360 | [ Session.saveActive newActive 361 | , if Session.isWork newActive.round.type_ then 362 | Spotify.play settings.spotify 363 | 364 | else 365 | Cmd.none 366 | ] 367 | in 368 | { model | playing = True, active = newActive } 369 | |> Misc.withCmd 370 | |> Misc.addCmd cmds 371 | 372 | Pause -> 373 | { model | playing = False } 374 | |> Misc.withCmd 375 | |> Misc.addCmd (Spotify.pause settings.spotify) 376 | 377 | Skip -> 378 | let 379 | ( nextIndex, nextRoundType ) = 380 | case List.Extra.getAt (active.index + 1) model.sessions of 381 | Just next -> 382 | ( active.index + 1, next ) 383 | 384 | Nothing -> 385 | ( 0, model.sessions |> Session.firstRound ) 386 | 387 | newActive : Session.ActiveRound 388 | newActive = 389 | Session.ActiveRound nextIndex (Session.newRound nextRoundType) 0 390 | 391 | cmds : Cmd msg 392 | cmds = 393 | Cmd.batch 394 | [ Session.logRound time active 395 | , Session.saveActive newActive 396 | , Spotify.pause settings.spotify 397 | ] 398 | in 399 | { model | active = newActive, playing = False } 400 | |> Misc.withCmd 401 | |> Misc.addCmd cmds 402 | 403 | Reset -> 404 | let 405 | newActive : Session.ActiveRound 406 | newActive = 407 | Session.newActiveRound sessions 408 | in 409 | { model | active = newActive, playing = False } 410 | |> Misc.withCmd 411 | |> Misc.addCmd 412 | (Cmd.batch 413 | [ Session.logRound time active 414 | , Session.saveActive newActive 415 | , Spotify.pause settings.spotify 416 | ] 417 | ) 418 | 419 | SetSentiment start sentiment -> 420 | { model | sentimentSession = Nothing } 421 | |> Misc.withCmd 422 | |> Misc.addCmd (Stats.setSentimentCmd start sentiment) 423 | 424 | 425 | 426 | -- HELPERS 427 | 428 | 429 | secondsToDisplay : Int -> String 430 | secondsToDisplay secs = 431 | let 432 | pad : Int -> String 433 | pad num = 434 | num |> String.fromInt |> String.padLeft 2 '0' 435 | in 436 | if secs < 60 then 437 | "0:" ++ pad secs 438 | 439 | else 440 | let 441 | min : Int 442 | min = 443 | (toFloat secs / 60) |> floor 444 | in 445 | String.fromInt min ++ ":" ++ pad (secs - (min * 60)) 446 | 447 | 448 | rollActiveRound : Time.Posix -> Int -> Settings.Flow -> List Session.RoundType -> ( Session.ActiveRound, Bool ) 449 | rollActiveRound now nextIndex flow rounds = 450 | let 451 | nextActive : Session.ActiveRound 452 | nextActive = 453 | case rounds |> List.Extra.getAt nextIndex of 454 | Nothing -> 455 | let 456 | firstRound : Session.RoundType 457 | firstRound = 458 | rounds |> Session.firstRound 459 | in 460 | Session.ActiveRound 0 (Session.newRound firstRound) 0 461 | 462 | Just nextRound -> 463 | Session.ActiveRound nextIndex (Session.newRound nextRound) 0 464 | in 465 | if Settings.shouldKeepPlaying nextActive.index flow then 466 | ( { nextActive | round = Session.setRoundStart now nextActive.round } 467 | , True 468 | ) 469 | 470 | else 471 | ( nextActive, False ) 472 | 473 | 474 | sessionChangeToFlash : Session.RoundType -> Session.RoundType -> ( Flash.FlashMsg, String ) 475 | sessionChangeToFlash from to = 476 | case Session.roundChangeToLabel from to of 477 | "" -> 478 | ( Flash.empty, "" ) 479 | 480 | label -> 481 | ( Flash.new label, label ) 482 | 483 | 484 | evalElapsedTime : Model a -> EvalResult msg 485 | evalElapsedTime { active, sessions, settings, time } = 486 | if Session.secondsLeft active == 0 then 487 | let 488 | nextIndex : Int 489 | nextIndex = 490 | active.index + 1 491 | 492 | ( newActive, playing ) = 493 | rollActiveRound time nextIndex settings.flow sessions 494 | 495 | ( flashMsg, notificationMsg ) = 496 | sessionChangeToFlash active.round.type_ newActive.round.type_ 497 | 498 | sentimentRound : Maybe Session.Round 499 | sentimentRound = 500 | if active.round.type_ |> Session.isWork then 501 | Just active.round 502 | 503 | else 504 | Nothing 505 | 506 | notificationCmd : Cmd msg 507 | notificationCmd = 508 | { sound = Settings.alarmSoundToEncodable settings.alarmSound 509 | , msg = notificationMsg 510 | , config = settings.notifications 511 | } 512 | |> encodeNotificationConfig 513 | |> Ports.notify 514 | 515 | spotifyCmd : Cmd msg 516 | spotifyCmd = 517 | if Session.isWork newActive.round.type_ then 518 | Spotify.play settings.spotify 519 | 520 | else 521 | Spotify.pause settings.spotify 522 | 523 | logCmd : Cmd msg 524 | logCmd = 525 | Session.logRound time active 526 | in 527 | EvalResult 528 | newActive 529 | playing 530 | (Just flashMsg) 531 | (Cmd.batch [ notificationCmd, spotifyCmd, logCmd ]) 532 | sentimentRound 533 | 534 | else 535 | EvalResult (Session.addElapsed 1 active) True Nothing Cmd.none Nothing 536 | 537 | 538 | updateTime : Time.Posix -> Model a -> Model a 539 | updateTime now model = 540 | { model | time = now, uptime = model.uptime + 1 } 541 | 542 | 543 | setupSentimentRound : 544 | Maybe Session.Round 545 | -> Session.RoundType 546 | -> Model a 547 | -> Model a 548 | setupSentimentRound round roundType model = 549 | let 550 | newRound : Maybe Session.Round 551 | newRound = 552 | case ( model.sentimentSession, round, Session.isWork roundType ) of 553 | ( _, _, True ) -> 554 | Nothing 555 | 556 | ( sentiment, Nothing, _ ) -> 557 | sentiment 558 | 559 | ( Nothing, sentiment, _ ) -> 560 | sentiment 561 | 562 | _ -> 563 | Nothing 564 | in 565 | { model | sentimentSession = newRound } 566 | 567 | 568 | tick : Time.Posix -> Model a -> ( Model a, Cmd msg ) 569 | tick posix ({ playing, flash, settings } as model) = 570 | if playing then 571 | let 572 | newState : EvalResult msg 573 | newState = 574 | evalElapsedTime model 575 | 576 | setFlashFn : Model a -> Model a 577 | setFlashFn = 578 | if settings.notifications.inApp then 579 | Flash.setFlash newState.flash 580 | 581 | else 582 | identity 583 | in 584 | { model 585 | | active = newState.active 586 | , playing = newState.playing 587 | , flash = flash |> Maybe.andThen Flash.updateFlashTime 588 | } 589 | |> setupSentimentRound newState.sentimentSession newState.active.round.type_ 590 | |> updateTime posix 591 | |> setFlashFn 592 | |> Misc.withCmd 593 | |> Misc.addCmd newState.cmd 594 | |> Misc.addCmd (Session.saveActive newState.active) 595 | 596 | else 597 | { model | flash = flash |> Maybe.andThen Flash.updateFlashTime } 598 | |> updateTime posix 599 | |> Misc.withCmd 600 | 601 | 602 | 603 | -- Functions "stolen" from https://stackoverflow.com/a/18473154/129676 604 | 605 | 606 | polarToCartesian : Float -> Float -> Float -> Float -> ( Float, Float ) 607 | polarToCartesian centerX centerY radius angleInDegrees = 608 | let 609 | angleInRadians : Float 610 | angleInRadians = 611 | (angleInDegrees - 90) * pi / 180.0 612 | in 613 | ( centerX + (radius * cos angleInRadians) 614 | , centerY + (radius * sin angleInRadians) 615 | ) 616 | 617 | 618 | describeArc : Float -> Float -> Float -> Float -> Float -> String 619 | describeArc x y radius startAngle endAngle = 620 | let 621 | ( startX, startY ) = 622 | polarToCartesian x y radius endAngle 623 | 624 | ( endX, endY ) = 625 | polarToCartesian x y radius startAngle 626 | 627 | largeArcFlag : String 628 | largeArcFlag = 629 | if endAngle - startAngle <= 180.0 then 630 | "0" 631 | 632 | else 633 | "1" 634 | in 635 | [ "M" 636 | , String.fromFloat startX 637 | , String.fromFloat startY 638 | , "A" 639 | , String.fromFloat radius 640 | , String.fromFloat radius 641 | , "0" 642 | , largeArcFlag 643 | , "0" 644 | , String.fromFloat endX 645 | , String.fromFloat endY 646 | ] 647 | |> String.join " " 648 | 649 | 650 | 651 | -- SUBSCRIPTIONS 652 | 653 | 654 | subscriptions : Sub Msg 655 | subscriptions = 656 | Ports.tick Tick 657 | 658 | 659 | 660 | -- CODECS 661 | 662 | 663 | encodeNotificationConfig : { sound : String, msg : String, config : Settings.Notifications } -> Encode.Value 664 | encodeNotificationConfig { sound, msg, config } = 665 | Encode.object 666 | [ ( "sound", Encode.string sound ) 667 | , ( "msg", Encode.string msg ) 668 | , ( "config", Settings.encodeNotifications config ) 669 | ] 670 | -------------------------------------------------------------------------------- /src/Page/Stats.elm: -------------------------------------------------------------------------------- 1 | module Page.Stats exposing 2 | ( Model 3 | , Msg 4 | , State 5 | , initialState 6 | , logsFetchCmd 7 | , setSentimentCmd 8 | , subscriptions 9 | , update 10 | , view 11 | ) 12 | 13 | import Calendar 14 | import Color 15 | import Css 16 | import Date 17 | import Elements 18 | import Html.Styled as Html 19 | import Html.Styled.Attributes as Attributes 20 | import Html.Styled.Events as Events 21 | import Html.Styled.Keyed as Keyed 22 | import Iso8601 23 | import Json.Decode as Decode 24 | import Json.Decode.Pipeline as Pipeline 25 | import Json.Encode as Encode 26 | import List.Extra 27 | import Misc 28 | import Page.MiniTimer as MiniTimer 29 | import Ports 30 | import Session 31 | import Theme 32 | import Theme.Common 33 | import Time 34 | import Tuple.Trio as Trio 35 | 36 | 37 | 38 | -- MODEL 39 | 40 | 41 | type alias Model a b = 42 | { a 43 | | time : Time.Posix 44 | , zone : Time.Zone 45 | , settings : { b | theme : Theme.Common.Theme } 46 | , active : Session.ActiveRound 47 | , sessions : List Session.RoundType 48 | } 49 | 50 | 51 | type State 52 | = Loading 53 | | Loaded Def 54 | 55 | 56 | type alias Def = 57 | { date : Date.Date 58 | , logs : List Session.Round 59 | , showLogs : Bool 60 | } 61 | 62 | 63 | 64 | -- VIEW 65 | 66 | 67 | view : Model a b -> State -> Html.Html Msg 68 | view ({ time, zone, settings } as model) state = 69 | Html.div [] 70 | [ MiniTimer.view model 71 | , Html.div 72 | [ Attributes.css 73 | [ Css.margin2 (Css.rem 2) Css.auto 74 | , Css.maxWidth <| Css.px 520 75 | ] 76 | ] 77 | [ Elements.h1 settings.theme "Statistics" 78 | , case state of 79 | Loaded def -> 80 | let 81 | today : Date.Date 82 | today = 83 | Date.fromPosix zone time 84 | in 85 | viewLoaded settings.theme zone today def 86 | 87 | _ -> 88 | Html.text "" 89 | ] 90 | ] 91 | 92 | 93 | viewLoaded : Theme.Common.Theme -> Time.Zone -> Date.Date -> Def -> Html.Html Msg 94 | viewLoaded theme zone today { date, logs, showLogs } = 95 | Html.div [] 96 | [ viewCalendar theme zone today date logs 97 | , viewDailySummary theme zone date logs 98 | , viewDailyLogs theme showLogs zone date logs 99 | , viewMonthlySummary theme logs 100 | , viewHourlyAverages theme zone logs 101 | ] 102 | 103 | 104 | viewCalendar : 105 | Theme.Common.Theme 106 | -> Time.Zone 107 | -> Date.Date 108 | -> Date.Date 109 | -> List Session.Round 110 | -> Html.Html Msg 111 | viewCalendar theme zone today date logs = 112 | let 113 | averages : List ( Date.Date, Float ) 114 | averages = 115 | monthlyAverages zone logs 116 | 117 | cellStyle : Css.Style 118 | cellStyle = 119 | Css.batch 120 | [ Css.displayFlex 121 | , Css.alignItems Css.center 122 | , Css.justifyContent Css.center 123 | , Css.height <| Css.rem 2.3 124 | ] 125 | 126 | averageForTheDay : Date.Date -> Float 127 | averageForTheDay day = 128 | averages 129 | |> List.Extra.find (Tuple.first >> (==) day) 130 | |> Maybe.map Tuple.second 131 | |> Maybe.withDefault 0 132 | |> Misc.flip (/) 100 133 | 134 | cellBgColor : Float -> Css.Color 135 | cellBgColor average = 136 | average 137 | |> Misc.flip Color.setAlpha (theme |> Theme.foregroundColor) 138 | |> Color.toCssColor 139 | 140 | cellTextColor : Float -> Css.Color 141 | cellTextColor average = 142 | if average < 0.5 then 143 | theme |> Theme.textColor |> Color.toCssColor 144 | 145 | else 146 | theme |> Theme.contrastColor |> Color.toCssColor 147 | 148 | cellBorder : Date.Date -> Css.Style 149 | cellBorder day = 150 | if day == date then 151 | Css.border3 (Css.rem 0.15) Css.solid (theme |> Theme.longBreakColor |> Color.toCssColor) 152 | 153 | else 154 | Css.borderStyle Css.none 155 | 156 | buildDay : Calendar.CalendarDate -> Html.Html Msg 157 | buildDay day = 158 | let 159 | average : Float 160 | average = 161 | averageForTheDay day.date 162 | 163 | style : Css.Style 164 | style = 165 | Css.batch 166 | [ Css.display Css.block 167 | , Css.width <| Css.pct 100 168 | , Css.height <| Css.pct 100 169 | , Css.backgroundColor (cellBgColor average) 170 | , Css.fontSize <| Css.rem 0.75 171 | , Css.boxSizing Css.borderBox 172 | , Css.color (cellTextColor average) 173 | ] 174 | 175 | renderFn : List (Html.Html Msg) -> Html.Html Msg 176 | renderFn = 177 | if day.dayDisplay == " " then 178 | Html.div [ Attributes.css [ style ] ] 179 | 180 | else 181 | Html.button 182 | [ Attributes.css [ style, Css.cursor Css.pointer, cellBorder day.date ] 183 | , if [ LT, EQ ] |> List.member (Date.compare day.date today) then 184 | Events.onClick (GoToDate day.date) 185 | 186 | else 187 | Events.onClick NoOp 188 | ] 189 | in 190 | Html.div 191 | [ Attributes.css [ cellStyle ] ] 192 | [ renderFn [ Html.text day.dayDisplay ] ] 193 | 194 | calendar : List (Html.Html Msg) 195 | calendar = 196 | date 197 | |> Calendar.fromDate Nothing 198 | |> List.concat 199 | |> List.map buildDay 200 | 201 | arrowStyle : Css.Style 202 | arrowStyle = 203 | Css.batch 204 | [ Css.width <| Css.rem 1.5 205 | , Css.height <| Css.rem 1.5 206 | , Css.borderStyle Css.none 207 | , Css.backgroundColor Css.transparent 208 | , Css.cursor Css.pointer 209 | , Css.color (theme |> Theme.textColor |> Color.toCssColor) 210 | ] 211 | 212 | arrow : 213 | Date.Date 214 | -> (Css.ExplicitLength Css.IncompatibleUnits -> Css.Style) 215 | -> String 216 | -> Html.Html Msg 217 | arrow date_ float icon = 218 | Html.button 219 | [ Attributes.css [ arrowStyle, Css.float float ] 220 | , if Date.compare date_ today == LT then 221 | Events.onClick <| GoToMonth date_ 222 | 223 | else 224 | Events.onClick NoOp 225 | ] 226 | [ Elements.icon icon ] 227 | 228 | asFirstDay : Date.Date -> Date.Date 229 | asFirstDay date_ = 230 | Date.fromCalendarDate 231 | (Date.year date_) 232 | (Date.month date_) 233 | 1 234 | 235 | prevMonth : Date.Date 236 | prevMonth = 237 | date |> Date.add Date.Months -1 |> asFirstDay 238 | 239 | nextMonth : Date.Date 240 | nextMonth = 241 | date |> Date.add Date.Months 1 |> asFirstDay 242 | in 243 | Html.div 244 | [ Attributes.css 245 | [ Css.margin2 (Css.rem 2) Css.auto 246 | , Css.maxWidth <| Css.px 280 247 | ] 248 | ] 249 | [ Html.div 250 | [ Attributes.css [ Css.position Css.relative, Css.marginBottom <| Css.rem 1 ] ] 251 | [ Elements.h2 theme 252 | (date |> Date.format "MMM / y") 253 | [] 254 | [ arrow prevMonth Css.left "chevron_left" 255 | , arrow nextMonth Css.right "chevron_right" 256 | ] 257 | ] 258 | , Html.div 259 | [ Attributes.css 260 | [ Css.property "display" "grid" 261 | , Css.property "grid-template-columns" "repeat(7, 1fr)" 262 | , Css.property "column-gap" ".2rem" 263 | , Css.property "row-gap" ".2rem" 264 | ] 265 | ] 266 | ([ "S", "M", "T", "W", "T", "F", "S" ] 267 | |> List.map 268 | (\wd -> 269 | Html.div 270 | [ Attributes.css [ cellStyle, Css.fontWeight Css.bold ] ] 271 | [ Html.div [] [ Html.text wd ] ] 272 | ) 273 | |> Misc.flip (++) calendar 274 | ) 275 | ] 276 | 277 | 278 | viewSummary : Theme.Common.Theme -> String -> List Session.Round -> Html.Html msg 279 | viewSummary theme label logs = 280 | if logs == [] then 281 | Html.text "" 282 | 283 | else 284 | let 285 | workLogs : List Session.Round 286 | workLogs = 287 | logs |> List.filter (.type_ >> Session.isWork) 288 | 289 | breakLogs : List Session.Round 290 | breakLogs = 291 | logs |> List.filter (.type_ >> Session.isAnyBreak) 292 | 293 | aggFn : List Session.Round -> ( Int, Int ) 294 | aggFn = 295 | List.foldl 296 | (\{ seconds, type_ } ( a, b ) -> 297 | ( a + (seconds |> Maybe.withDefault 0) 298 | , b + Session.roundSeconds type_ 299 | ) 300 | ) 301 | ( 0, 0 ) 302 | 303 | ( workRealSecs, workTotalSecs ) = 304 | aggFn workLogs 305 | 306 | ( breakRealSecs, breakTotalSecs ) = 307 | aggFn breakLogs 308 | 309 | workPct : Int 310 | workPct = 311 | workRealSecs * 100 // workTotalSecs 312 | 313 | breakPct : Int 314 | breakPct = 315 | breakRealSecs * 100 // breakTotalSecs 316 | 317 | sentiment : Session.Sentiment 318 | sentiment = 319 | Session.calculateSentiment logs 320 | in 321 | Html.div [ Attributes.css [ Css.marginBottom <| Css.rem 2 ] ] 322 | [ Elements.h2 theme label [ Attributes.css [ Css.marginBottom <| Css.rem 1 ] ] [] 323 | , Html.div [ Attributes.css [ Css.textAlign Css.center, Css.marginBottom <| Css.rem 2 ] ] 324 | [ Elements.h3 theme 325 | "Activity time" 326 | [ Attributes.css [ Css.marginBottom <| Css.rem 0.5 ] ] 327 | [ Html.small [] [ Html.text " (in minutes)" ] ] 328 | , Html.div 329 | [ Attributes.css [ Css.marginBottom <| Css.rem 1 ] 330 | ] 331 | [ Html.text (workRealSecs |> inMinutes |> String.fromInt) 332 | , Html.small [] [ Html.text (" (" ++ (workPct |> String.fromInt) ++ "%)") ] 333 | ] 334 | , Elements.h3 theme 335 | "Break time" 336 | [ Attributes.css [ Css.marginBottom <| Css.rem 0.5 ] ] 337 | [ Html.small [] [ Html.text " (in minutes)" ] ] 338 | , Html.div 339 | [ Attributes.css [ Css.marginBottom <| Css.rem 1 ] 340 | ] 341 | [ Html.text (breakRealSecs |> inMinutes |> String.fromInt) 342 | , Html.small [] [ Html.text (" (" ++ (breakPct |> String.fromInt) ++ "%)") ] 343 | ] 344 | , Elements.h3 theme 345 | "Sentiment" 346 | [ Attributes.css [ Css.marginBottom <| Css.rem 0.5 ] ] 347 | [] 348 | , Html.div [ Attributes.title (Session.sentimentToDisplay sentiment) ] 349 | [ Elements.styledIcon 350 | [ Css.fontSize <| Css.rem 2 ] 351 | (Session.sentimentToIcon sentiment) 352 | ] 353 | ] 354 | ] 355 | 356 | 357 | viewDailySummary : Theme.Common.Theme -> Time.Zone -> Date.Date -> List Session.Round -> Html.Html msg 358 | viewDailySummary theme zone date logs = 359 | viewSummary theme "Daily summary" (dailyLogs zone date logs) 360 | 361 | 362 | viewDailyLogs : Theme.Common.Theme -> Bool -> Time.Zone -> Date.Date -> List Session.Round -> Html.Html Msg 363 | viewDailyLogs theme show zone selected logs = 364 | let 365 | formatToHour : Time.Posix -> String 366 | formatToHour t = 367 | ( t, t ) 368 | |> Tuple.mapBoth 369 | (Time.toHour zone >> String.fromInt >> String.padLeft 2 '0') 370 | (Time.toMinute zone >> String.fromInt >> String.padLeft 2 'o') 371 | |> (\( h, m ) -> h ++ ":" ++ m) 372 | 373 | renderSentiment : Maybe Session.Sentiment -> Time.Posix -> Html.Html Msg 374 | renderSentiment sentiment start = 375 | let 376 | opacity : Msg -> Css.Style 377 | opacity msg = 378 | sentiment 379 | |> Maybe.map 380 | (\s -> 381 | if UpdateSentiment start s == msg then 382 | Css.opacity <| Css.num 1 383 | 384 | else 385 | Css.opacity <| Css.num 0.5 386 | ) 387 | |> Maybe.withDefault (Css.opacity <| Css.num 0.5) 388 | in 389 | Html.div 390 | [ Attributes.css 391 | [ Css.position Css.absolute 392 | , Css.top <| Css.rem 0.25 393 | , Css.right <| Css.rem 0.25 394 | ] 395 | ] 396 | [ Html.ul 397 | [ Attributes.css [ Css.listStyle Css.none ] ] 398 | ([ ( "Positive", UpdateSentiment start Session.positive, "sentiment_satisfied" ) 399 | , ( "Neutral", UpdateSentiment start Session.neutral, "sentiment_neutral" ) 400 | , ( "Negative", UpdateSentiment start Session.negative, "sentiment_dissatisfied" ) 401 | ] 402 | |> List.map 403 | (\( label, msg, icon ) -> 404 | Html.li [ Attributes.css [ Css.display Css.inlineBlock ] ] 405 | [ Html.button 406 | [ Events.onClick msg 407 | , Attributes.title label 408 | , Attributes.css 409 | [ Css.backgroundColor Css.transparent 410 | , Css.border Css.zero 411 | , Css.padding Css.zero 412 | , Css.margin Css.zero 413 | , Css.cursor Css.pointer 414 | , opacity msg 415 | ] 416 | ] 417 | [ Elements.styledIcon 418 | [ Css.color (theme |> Theme.contrastColor |> Color.toCssColor) ] 419 | icon 420 | ] 421 | ] 422 | ) 423 | ) 424 | ] 425 | 426 | renderRound : 427 | Maybe Session.Sentiment 428 | -> Session.RoundType 429 | -> Time.Posix 430 | -> Time.Posix 431 | -> Int 432 | -> Html.Html Msg 433 | renderRound sentiment round start end seconds = 434 | let 435 | innerPct : String 436 | innerPct = 437 | round 438 | |> Session.roundSeconds 439 | |> (\t -> 100 * seconds // t) 440 | |> String.fromInt 441 | 442 | roundColor : Color.Color 443 | roundColor = 444 | round |> Session.roundToColor theme 445 | 446 | dimmed : String 447 | dimmed = 448 | roundColor |> Color.setAlpha 0.5 |> Color.toRgbaString 449 | 450 | full : String 451 | full = 452 | roundColor |> Color.toRgbaString 453 | in 454 | Html.div 455 | [ Attributes.css 456 | [ Css.padding <| Css.rem 0.5 457 | , Css.position Css.relative 458 | , Css.margin2 (Css.rem 0.5) Css.zero 459 | , Css.color (theme |> Theme.contrastColor |> Color.toCssColor) 460 | , Css.lineHeight <| Css.rem 1 461 | , Css.property "background-image" 462 | ("linear-gradient(to right, " 463 | ++ full 464 | ++ ", " 465 | ++ full 466 | ++ " " 467 | ++ innerPct 468 | ++ "%, " 469 | ++ dimmed 470 | ++ " " 471 | ++ innerPct 472 | ++ "%, " 473 | ++ dimmed 474 | ++ " 100%)" 475 | ) 476 | ] 477 | ] 478 | [ Html.div 479 | [] 480 | [ Html.text (formatToHour start ++ " ➞ " ++ formatToHour end) 481 | , if Session.isWork round then 482 | renderSentiment sentiment start 483 | 484 | else 485 | Html.text "" 486 | ] 487 | ] 488 | 489 | dailyLogs_ : List Session.Round 490 | dailyLogs_ = 491 | dailyLogs zone selected logs 492 | in 493 | if dailyLogs_ /= [] then 494 | Html.div 495 | [ Attributes.css 496 | [ Css.color (theme |> Theme.textColor |> Color.toCssColor) 497 | , Css.marginBottom <| Css.rem 2 498 | ] 499 | ] 500 | [ Html.div [] 501 | [ Html.div [] 502 | [ Html.button 503 | [ Events.onClick ToggleDailyLogs 504 | , Attributes.css 505 | [ Css.borderStyle Css.none 506 | , Css.backgroundColor <| (theme |> Theme.foregroundColor |> Color.toCssColor) 507 | , Css.width <| Css.rem 14 508 | , Css.height <| Css.rem 2.5 509 | , Css.color <| (theme |> Theme.backgroundColor |> Color.toCssColor) 510 | , Css.outline Css.zero 511 | , Css.cursor Css.pointer 512 | , Css.display Css.block 513 | , Css.margin2 Css.zero Css.auto 514 | , Css.marginBottom <| Css.rem 2 515 | ] 516 | ] 517 | [ Html.text 518 | (if show then 519 | "Hide logs" 520 | 521 | else 522 | "Show logs for the day" 523 | ) 524 | ] 525 | ] 526 | , if show then 527 | dailyLogs_ 528 | |> List.sortBy (.start >> Maybe.map Time.posixToMillis >> Maybe.withDefault 0) 529 | |> List.filterMap 530 | (\{ type_, start, end, seconds, sentiment } -> 531 | Maybe.map3 532 | (\s e sc -> 533 | ( s |> Time.posixToMillis |> String.fromInt 534 | , renderRound sentiment type_ s e sc 535 | ) 536 | ) 537 | start 538 | end 539 | seconds 540 | ) 541 | |> Keyed.node "div" [] 542 | 543 | else 544 | Html.text "" 545 | ] 546 | ] 547 | 548 | else 549 | Html.text "" 550 | 551 | 552 | viewMonthlySummary : Theme.Common.Theme -> List Session.Round -> Html.Html msg 553 | viewMonthlySummary theme logs = 554 | viewSummary theme "Monthly summary" logs 555 | 556 | 557 | viewHourlyAverages : Theme.Common.Theme -> Time.Zone -> List Session.Round -> Html.Html msg 558 | viewHourlyAverages theme zone log = 559 | if log == [] then 560 | Html.text "" 561 | 562 | else 563 | let 564 | averages : List ( Int, Int, Float ) 565 | averages = 566 | hourlyAverages zone log 567 | 568 | loggedHours : List Int 569 | loggedHours = 570 | averages |> List.map Trio.first 571 | 572 | hours : List Int 573 | hours = 574 | List.range 575 | (loggedHours |> List.minimum |> Maybe.withDefault 0) 576 | (loggedHours |> List.maximum |> Maybe.withDefault 23) 577 | in 578 | Html.div [ Attributes.css [ Css.marginBottom <| Css.rem 2 ] ] 579 | [ Elements.h2 theme "Most productive hours" [ Attributes.css [ Css.marginBottom <| Css.rem 2 ] ] [] 580 | , hours 581 | |> List.map 582 | (\h -> 583 | averages 584 | |> List.Extra.find (Trio.first >> (==) h) 585 | |> Maybe.map 586 | (\( _, secs, pct ) -> 587 | Html.div 588 | [ Attributes.css 589 | [ Css.width <| Css.pct 100 590 | , Css.height <| Css.pct pct 591 | , Css.backgroundColor (theme |> Theme.longBreakColor |> Color.toCssColor) 592 | , Css.margin2 Css.zero (Css.rem 0.25) 593 | ] 594 | , Attributes.title (inMinutes secs |> String.fromInt) 595 | ] 596 | [] 597 | ) 598 | |> Maybe.withDefault 599 | (Html.div 600 | [ Attributes.css 601 | [ Css.margin2 Css.zero (Css.rem 0.25) 602 | , Css.width <| Css.pct 100 603 | ] 604 | ] 605 | [ Html.text "" ] 606 | ) 607 | ) 608 | |> Html.div 609 | [ Attributes.css 610 | [ Css.displayFlex 611 | , Css.alignItems Css.flexEnd 612 | , Css.height <| Css.rem 5 613 | , Css.width <| Css.pct 100 614 | ] 615 | ] 616 | , Html.div 617 | [ Attributes.css 618 | [ Css.borderTop <| Css.px 1 619 | , Css.borderStyle Css.solid 620 | , Css.borderRight Css.zero 621 | , Css.borderBottom Css.zero 622 | , Css.borderLeft Css.zero 623 | , Css.paddingTop <| Css.rem 0.35 624 | , Css.fontSize <| Css.rem 0.5 625 | , Css.color (theme |> Theme.textColor |> Color.toCssColor) 626 | ] 627 | ] 628 | (hours 629 | |> List.map 630 | (\h -> 631 | Html.div 632 | [ Attributes.css 633 | [ Css.width <| Css.pct 100 634 | , Css.margin2 Css.zero (Css.rem 0.25) 635 | , Css.textAlign Css.center 636 | , Css.overflow Css.hidden 637 | ] 638 | ] 639 | [ Html.text (h |> String.fromInt |> String.padLeft 2 '0') ] 640 | ) 641 | |> Html.div 642 | [ Attributes.css 643 | [ Css.displayFlex 644 | , Css.width <| Css.pct 100 645 | ] 646 | ] 647 | |> List.singleton 648 | ) 649 | ] 650 | 651 | 652 | 653 | -- UPDATE 654 | 655 | 656 | type Msg 657 | = NoOp 658 | | GotLogs Decode.Value 659 | | GoToDate Date.Date 660 | | GoToMonth Date.Date 661 | | UpdateSentiment Time.Posix Session.Sentiment 662 | | ToggleDailyLogs 663 | 664 | 665 | update : Time.Zone -> Msg -> State -> ( State, Cmd msg ) 666 | update zone msg state = 667 | case msg of 668 | NoOp -> 669 | Misc.withCmd state 670 | 671 | GotLogs raw -> 672 | let 673 | toDate : Int -> Date.Date 674 | toDate = 675 | Time.millisToPosix >> Date.fromPosix zone 676 | in 677 | case ( Decode.decodeValue decodeLogs raw, state ) of 678 | ( Ok { ts, logs }, Loading ) -> 679 | Loaded (Def (ts |> toDate) logs False) |> Misc.withCmd 680 | 681 | ( Ok { ts, logs }, Loaded def ) -> 682 | Loaded { def | date = ts |> toDate, logs = logs } |> Misc.withCmd 683 | 684 | _ -> 685 | state |> Misc.withCmd 686 | 687 | GoToDate newDate -> 688 | state 689 | |> mapDef (\d -> { d | date = newDate }) 690 | |> Misc.withCmd 691 | 692 | GoToMonth date -> 693 | date 694 | |> Date.add Date.Days 1 695 | |> Date.toIsoString 696 | |> Iso8601.toTime 697 | |> Result.map (logsFetchCmd >> Tuple.pair state) 698 | |> Result.withDefault (state |> Misc.withCmd) 699 | 700 | UpdateSentiment start sentiment -> 701 | state 702 | |> mapDef 703 | (\def -> 704 | let 705 | newLogs : List Session.Round 706 | newLogs = 707 | def.logs 708 | |> List.Extra.findIndex (.start >> (==) (Just start)) 709 | |> Maybe.map 710 | (\idx -> 711 | def.logs 712 | |> List.Extra.updateAt idx 713 | (\cycle -> { cycle | sentiment = Just sentiment }) 714 | ) 715 | |> Maybe.withDefault def.logs 716 | in 717 | { def | logs = newLogs } 718 | ) 719 | |> Misc.withCmd 720 | |> Misc.addCmd (setSentimentCmd start sentiment) 721 | 722 | ToggleDailyLogs -> 723 | state 724 | |> mapDef (\d -> { d | showLogs = not d.showLogs }) 725 | |> Misc.withCmd 726 | 727 | 728 | 729 | -- HELPERS 730 | 731 | 732 | hourlyAverages : Time.Zone -> List Session.Round -> List ( Int, Int, Float ) 733 | hourlyAverages zone log = 734 | let 735 | aggregate : 736 | List ( Int, Int, Int ) 737 | -> { a | start : Time.Posix, seconds : Int } 738 | -> List ( Int, Int, Int ) 739 | aggregate agg { start, seconds } = 740 | let 741 | hour : Int 742 | hour = 743 | start |> Time.toHour zone 744 | in 745 | case agg |> List.Extra.findIndex (Trio.first >> (==) hour) of 746 | Just idx -> 747 | agg |> List.Extra.updateAt idx (\( h, count, secs ) -> ( h, count + 1, secs + seconds )) 748 | 749 | Nothing -> 750 | ( hour, 1, seconds ) :: agg 751 | 752 | firstPass : List ( Int, Int ) 753 | firstPass = 754 | log 755 | |> List.filter (.type_ >> Session.isWork) 756 | |> List.foldl 757 | (\round agg -> 758 | round 759 | |> Session.materializedRound 760 | |> Maybe.map (aggregate agg) 761 | |> Maybe.withDefault agg 762 | ) 763 | [] 764 | |> List.map (\( h, count, secs ) -> ( h, secs // count )) 765 | 766 | max : Int 767 | max = 768 | firstPass |> List.Extra.maximumBy Tuple.second |> Maybe.map Tuple.second |> Maybe.withDefault 0 769 | in 770 | firstPass |> List.map (\( h, secs ) -> ( h, secs, toFloat secs * 100 / toFloat max )) 771 | 772 | 773 | dailyLogs : Time.Zone -> Date.Date -> List Session.Round -> List Session.Round 774 | dailyLogs zone day logs = 775 | logs 776 | |> List.filter 777 | (.start 778 | >> Maybe.map (Date.fromPosix zone >> Date.compare day >> (==) EQ) 779 | >> Maybe.withDefault False 780 | ) 781 | 782 | 783 | inMinutes : Int -> Int 784 | inMinutes secs = 785 | secs // 60 786 | 787 | 788 | monthlyAverages : Time.Zone -> List Session.Round -> List ( Date.Date, Float ) 789 | monthlyAverages zone log = 790 | let 791 | aggregate : 792 | List ( Date.Date, Int ) 793 | -> { a | start : Time.Posix, seconds : Int } 794 | -> List ( Date.Date, Int ) 795 | aggregate agg { start, seconds } = 796 | let 797 | date : Date.Date 798 | date = 799 | start |> Date.fromPosix zone 800 | in 801 | case agg |> List.Extra.findIndex (Tuple.first >> (==) date) of 802 | Just idx -> 803 | agg |> List.Extra.updateAt idx (\( d, s ) -> ( d, s + seconds )) 804 | 805 | Nothing -> 806 | ( date, seconds ) :: agg 807 | 808 | firstPass : List ( Date.Date, Int ) 809 | firstPass = 810 | log 811 | |> List.filter (.type_ >> Session.isWork) 812 | |> List.foldl 813 | (\round agg -> 814 | round 815 | |> Session.materializedRound 816 | |> Maybe.map (aggregate agg) 817 | |> Maybe.withDefault agg 818 | ) 819 | [] 820 | 821 | max : Int 822 | max = 823 | firstPass |> List.Extra.maximumBy Tuple.second |> Maybe.map Tuple.second |> Maybe.withDefault 0 824 | in 825 | firstPass |> List.map (\( date, seconds ) -> ( date, (toFloat seconds * 100) / toFloat max )) 826 | 827 | 828 | mapDef : (Def -> Def) -> State -> State 829 | mapDef map state = 830 | case state of 831 | Loaded def -> 832 | Loaded <| map def 833 | 834 | Loading -> 835 | Loading 836 | 837 | 838 | initialState : State 839 | initialState = 840 | Loading 841 | 842 | 843 | 844 | -- PORTS INTERFACE 845 | 846 | 847 | type PortAction 848 | = SetSentiment Time.Posix Session.Sentiment 849 | | Fetch Time.Posix 850 | 851 | 852 | encodePortAction : PortAction -> Encode.Value 853 | encodePortAction action = 854 | case action of 855 | SetSentiment time sentiment -> 856 | Encode.object 857 | [ ( "type", Encode.string "sentiment" ) 858 | , ( "time", Misc.encodePosix time ) 859 | , ( "sentiment", Session.encodeSentiment sentiment ) 860 | ] 861 | 862 | Fetch time -> 863 | Encode.object 864 | [ ( "type", Encode.string "fetch" ) 865 | , ( "time", Misc.encodePosix time ) 866 | ] 867 | 868 | 869 | toPort : PortAction -> Cmd msg 870 | toPort = 871 | encodePortAction >> Ports.toLog 872 | 873 | 874 | setSentimentCmd : Time.Posix -> Session.Sentiment -> Cmd msg 875 | setSentimentCmd start sentiment = 876 | SetSentiment start sentiment |> toPort 877 | 878 | 879 | logsFetchCmd : Time.Posix -> Cmd msg 880 | logsFetchCmd = 881 | Fetch >> toPort 882 | 883 | 884 | 885 | -- SUBSCRIPTIONS 886 | 887 | 888 | subscriptions : Sub Msg 889 | subscriptions = 890 | Ports.gotFromLog GotLogs 891 | 892 | 893 | 894 | -- CODECS 895 | 896 | 897 | decodeLogs : Decode.Decoder { ts : Int, logs : List Session.Round } 898 | decodeLogs = 899 | Decode.succeed (\ts l -> { ts = ts, logs = l }) 900 | |> Pipeline.required "ts" Decode.int 901 | |> Pipeline.required "logs" (Decode.list Session.decodeRound) 902 | --------------------------------------------------------------------------------