├── .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 |
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 |
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 |
--------------------------------------------------------------------------------