├── system.properties ├── README.md ├── Procfile ├── src ├── cljc │ └── typerooni │ │ └── util.cljc ├── clj │ └── typerooni │ │ ├── server.clj │ │ └── handler.clj └── cljs │ └── typerooni │ └── core.cljs ├── env ├── prod │ ├── cljs │ │ └── typerooni │ │ │ └── prod.cljs │ └── clj │ │ └── typerooni │ │ └── middleware.clj └── dev │ ├── cljs │ └── typerooni │ │ └── dev.cljs │ └── clj │ └── typerooni │ ├── middleware.clj │ └── repl.clj ├── .gitignore ├── LICENSE ├── resources └── public │ └── css │ └── site.css └── project.clj /system.properties: -------------------------------------------------------------------------------- 1 | java.runtime.version=1.8 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typerooni 2 | Typing Test app made in Clojurescript 3 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: java $JVM_OPTS -cp target/typerooni.jar clojure.main -m typerooni.server 2 | -------------------------------------------------------------------------------- /src/cljc/typerooni/util.cljc: -------------------------------------------------------------------------------- 1 | (ns typerooni.util) 2 | 3 | (defn foo-cljx [x] 4 | "I don't do a whole lot." 5 | [x] 6 | (println x "Hello, World!")) 7 | -------------------------------------------------------------------------------- /env/prod/cljs/typerooni/prod.cljs: -------------------------------------------------------------------------------- 1 | (ns typerooni.prod 2 | (:require [typerooni.core :as core])) 3 | 4 | ;;ignore println statements in prod 5 | (set! *print-fn* (fn [& _])) 6 | 7 | (core/init!) 8 | -------------------------------------------------------------------------------- /env/prod/clj/typerooni/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns typerooni.middleware 2 | (:require [ring.middleware.defaults :refer [site-defaults wrap-defaults]])) 3 | 4 | (defn wrap-middleware [handler] 5 | (wrap-defaults handler site-defaults)) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | /classes 3 | /checkouts 4 | profiles.clj 5 | pom.xml 6 | pom.xml.asc 7 | *.jar 8 | *.class 9 | /.lein-* 10 | /.nrepl-port 11 | /resources/public/js 12 | /out 13 | /.repl 14 | *.log 15 | /.env 16 | resources/public/css/site.min.css -------------------------------------------------------------------------------- /src/clj/typerooni/server.clj: -------------------------------------------------------------------------------- 1 | (ns typerooni.server 2 | (:require [typerooni.handler :refer [app]] 3 | [environ.core :refer [env]] 4 | [ring.adapter.jetty :refer [run-jetty]]) 5 | (:gen-class)) 6 | 7 | (defn -main [& args] 8 | (let [port (Integer/parseInt (or (env :port) "3000"))] 9 | (run-jetty app {:port port :join? false}))) 10 | -------------------------------------------------------------------------------- /env/dev/cljs/typerooni/dev.cljs: -------------------------------------------------------------------------------- 1 | (ns ^:figwheel-no-load typerooni.dev 2 | (:require [typerooni.core :as core] 3 | [figwheel.client :as figwheel :include-macros true])) 4 | 5 | (enable-console-print!) 6 | 7 | (figwheel/watch-and-reload 8 | :websocket-url "ws://192.168.1.104:3449/figwheel-ws" 9 | :jsload-callback core/mount-root) 10 | 11 | (core/init!) 12 | -------------------------------------------------------------------------------- /env/dev/clj/typerooni/middleware.clj: -------------------------------------------------------------------------------- 1 | (ns typerooni.middleware 2 | (:require [ring.middleware.defaults :refer [site-defaults wrap-defaults]] 3 | [prone.middleware :refer [wrap-exceptions]] 4 | [ring.middleware.reload :refer [wrap-reload]])) 5 | 6 | (defn wrap-middleware [handler] 7 | (-> handler 8 | (wrap-defaults site-defaults) 9 | wrap-exceptions 10 | wrap-reload)) 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Dominic Muller 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/clj/typerooni/handler.clj: -------------------------------------------------------------------------------- 1 | (ns typerooni.handler 2 | (:require [compojure.core :refer [GET defroutes]] 3 | [compojure.route :refer [not-found resources]] 4 | [hiccup.core :refer [html]] 5 | [hiccup.page :refer [include-js include-css]] 6 | [typerooni.middleware :refer [wrap-middleware]] 7 | [environ.core :refer [env]])) 8 | 9 | (def mount-target 10 | [:div#app "One moment..."] 11 | #_[:div#app 12 | [:h3 "ClojureScript has not been compiled!"] 13 | [:p "please run " 14 | [:b "lein figwheel"] 15 | " in order to start the compiler"]]) 16 | 17 | (def loading-page 18 | (html 19 | [:html 20 | [:head 21 | [:meta {:charset "utf-8"}] 22 | [:meta {:name "viewport" 23 | :content "width=device-width, initial-scale=1"}] 24 | (include-css (if (env :dev) "css/site.css" "css/site.min.css"))] 25 | [:body 26 | mount-target 27 | (include-js "js/app.js")]])) 28 | 29 | 30 | (defroutes routes 31 | (GET "/" [] loading-page) 32 | (GET "/about" [] loading-page) 33 | (GET "/test" [] loading-page) 34 | 35 | (resources "/") 36 | (not-found "Not Found")) 37 | 38 | (def app (wrap-middleware #'routes)) 39 | -------------------------------------------------------------------------------- /env/dev/clj/typerooni/repl.clj: -------------------------------------------------------------------------------- 1 | (ns typerooni.repl 2 | (:use typerooni.handler 3 | ring.server.standalone 4 | [ring.middleware file-info file])) 5 | 6 | (defonce server (atom nil)) 7 | 8 | (defn get-handler [] 9 | ;; #'app expands to (var app) so that when we reload our code, 10 | ;; the server is forced to re-resolve the symbol in the var 11 | ;; rather than having its own copy. When the root binding 12 | ;; changes, the server picks it up without having to restart. 13 | (-> #'app 14 | ; Makes static assets in $PROJECT_DIR/resources/public/ available. 15 | (wrap-file "resources") 16 | ; Content-Type, Content-Length, and Last Modified headers for files in body 17 | (wrap-file-info))) 18 | 19 | (defn start-server 20 | "used for starting the server in development mode from REPL" 21 | [& [port]] 22 | (let [port (if port (Integer/parseInt port) 3000)] 23 | (reset! server 24 | (serve (get-handler) 25 | {:port port 26 | :auto-reload? true 27 | :join? false})) 28 | (println (str "You can view the site at http://localhost:" port)))) 29 | 30 | (defn stop-server [] 31 | (.stop @server) 32 | (reset! server nil)) 33 | -------------------------------------------------------------------------------- /resources/public/css/site.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Helvetica Neue', Verdana, Helvetica, Arial, sans-serif; 3 | max-width: 600px; 4 | margin: 0 auto; 5 | padding-top: 72px; 6 | -webkit-font-smoothing: antialiased; 7 | font-size: 1.125em; 8 | color: #333; 9 | line-height: 1.5em; 10 | } 11 | 12 | h1, h2, h3 { 13 | color: #000; 14 | } 15 | h1 { 16 | font-size: 2.5em 17 | } 18 | 19 | h2 { 20 | font-size: 2em 21 | } 22 | 23 | h3 { 24 | font-size: 1.5em 25 | } 26 | 27 | a { 28 | text-decoration: none; 29 | color: #09f; 30 | } 31 | 32 | a:hover { 33 | text-decoration: underline; 34 | } 35 | 36 | .target-word { 37 | padding-right: 0.3em; 38 | padding-left: 0.3em; 39 | display: inline-block; 40 | line-height: 1.45em; 41 | font-size: 2em; 42 | } 43 | 44 | span.current { 45 | background-color: #246781; 46 | color: white; 47 | border-radius: 6px; 48 | } 49 | 50 | span.correct { 51 | color: green; 52 | } 53 | 54 | span.incorrect { 55 | color: #C52; 56 | } 57 | table, th, td { 58 | border: 1px solid black; 59 | border-collapse: collapse; 60 | } 61 | 62 | #games-analysis, #games-analysis-wordlets { 63 | clear: both; 64 | } 65 | 66 | #games-analysis-total-words { 67 | padding-right: 10px; 68 | } 69 | 70 | #games-analysis-correct-words { 71 | color: green; 72 | padding-right: 10px; 73 | } 74 | 75 | #games-analysis-incorrect-words { 76 | color: red; 77 | padding-right: 10px; 78 | } 79 | 80 | #games-analysis-wpm { 81 | color: blue; 82 | } 83 | 84 | #game-timer { 85 | margin-left: 20px; 86 | border: 1px solid black; 87 | padding: 4px; 88 | border-radius: 3px; 89 | } 90 | 91 | .game-over { 92 | background-color: lightgrey; 93 | } 94 | 95 | #target-word-view { 96 | width: 800px; 97 | height: 8.66em; 98 | overflow: hidden; 99 | border: 1px solid grey; 100 | padding: 8px; 101 | border-radius: 6px; 102 | box-shadow: 2px 2px 2px grey; 103 | margin-bottom: 20px; 104 | } 105 | 106 | #target-words { 107 | position: relative; 108 | transition: top 0.3s; 109 | transition-timing-function: ease-in-out; 110 | } 111 | -------------------------------------------------------------------------------- /project.clj: -------------------------------------------------------------------------------- 1 | (defproject typerooni "0.1.0-SNAPSHOT" 2 | 3 | :description "FIXME: write description" 4 | 5 | :url "http://example.com/FIXME" 6 | 7 | :license { 8 | :name "Eclipse Public License" 9 | :url "http://www.eclipse.org/legal/epl-v10.html"} 10 | 11 | :dependencies [ 12 | [org.clojure/clojure "1.7.0"] 13 | [org.clojure/core.async "0.2.374"] 14 | [ring-server "0.4.0"] 15 | [reagent "0.5.1" 16 | :exclusions [org.clojure/tools.reader]] 17 | [reagent-forms "0.5.13"] 18 | [reagent-utils "0.1.7"] 19 | [ring "1.4.0"] 20 | [ring/ring-defaults "0.1.5"] 21 | [compojure "1.4.0"] 22 | [hiccup "1.0.5"] 23 | [environ "1.0.1"] 24 | [org.clojure/clojurescript "1.7.170" 25 | :scope "provided"] 26 | [secretary "1.2.3"] 27 | [venantius/accountant "0.1.6" 28 | :exclusions [org.clojure/tools.reader]]] 29 | 30 | :plugins [ 31 | [lein-environ "1.0.1"] 32 | [lein-cljsbuild "1.1.1"] 33 | [lein-asset-minifier "0.2.4" 34 | :exclusions [org.clojure/clojure]]] 35 | 36 | :ring { 37 | :handler typerooni.handler/app 38 | :uberwar-name "typerooni.war"} 39 | 40 | :min-lein-version "2.5.0" 41 | 42 | :uberjar-name "typerooni.jar" 43 | 44 | :main typerooni.server 45 | 46 | :clean-targets ^{:protect false} 47 | [:target-path 48 | [:cljsbuild :builds :app :compiler :output-dir] 49 | [:cljsbuild :builds :app :compiler :output-to]] 50 | 51 | :source-paths ["src/clj" "src/cljc"] 52 | 53 | :resource-paths ["resources" "target/cljsbuild"] 54 | 55 | :minify-assets { 56 | :assets { 57 | "resources/public/css/site.min.css" "resources/public/css/site.css"}} 58 | 59 | :cljsbuild { 60 | :builds { 61 | :app { 62 | :source-paths ["src/cljs" "src/cljc"] 63 | :compiler { 64 | :output-to "target/cljsbuild/public/js/app.js" 65 | :output-dir "target/cljsbuild/public/js/out" 66 | :asset-path "js/out" 67 | :optimizations :none 68 | :pretty-print true}}}} 69 | 70 | :profiles { 71 | :dev { 72 | :repl-options {:init-ns typerooni.repl} 73 | :dependencies [ 74 | [ring/ring-mock "0.3.0"] 75 | [ring/ring-devel "1.4.0"] 76 | [prone "0.8.3"] 77 | [lein-figwheel "0.5.0-2" 78 | :exclusions [ 79 | org.clojure/core.memoize 80 | ring/ring-core 81 | org.clojure/clojure 82 | org.ow2.asm/asm-all 83 | org.clojure/data.priority-map 84 | org.clojure/tools.reader 85 | org.clojure/clojurescript 86 | org.clojure/core.async 87 | org.clojure/tools.analyzer.jvm]] 88 | [org.clojure/clojurescript "1.7.170" 89 | :exclusions [ 90 | org.clojure/clojure 91 | org.clojure/tools.reader]] 92 | [org.clojure/tools.nrepl "0.2.12"] 93 | [com.cemerick/piggieback "0.2.1"] 94 | [pjstadig/humane-test-output "0.7.1"]] 95 | :source-paths ["env/dev/clj"] 96 | :plugins [ 97 | [lein-figwheel "0.5.0-2" 98 | :exclusions [ 99 | org.clojure/core.memoize 100 | ring/ring-core 101 | org.clojure/clojure 102 | org.ow2.asm/asm-all 103 | org.clojure/data.priority-map 104 | org.clojure/tools.reader 105 | org.clojure/clojurescript 106 | org.clojure/core.async 107 | org.clojure/tools.analyzer.jvm]] 108 | [org.clojure/clojurescript "1.7.170"]] 109 | :injections [ 110 | (require 'pjstadig.humane-test-output) 111 | (pjstadig.humane-test-output/activate!)] 112 | :figwheel { 113 | :http-server-root "public" 114 | :server-port 3449 115 | :nrepl-port 7002 116 | :nrepl-middleware ["cemerick.piggieback/wrap-cljs-repl"] 117 | :css-dirs ["resources/public/css"] 118 | :ring-handler typerooni.handler/app} 119 | :env {:dev true} 120 | :cljsbuild { 121 | :builds { 122 | :app { 123 | :source-paths ["env/dev/cljs"] 124 | :compiler { 125 | :main "typerooni.dev" 126 | :source-map true}}}}} 127 | 128 | :uberjar { 129 | :hooks [minify-assets.plugin/hooks] 130 | :source-paths ["env/prod/clj"] 131 | :prep-tasks ["compile" ["cljsbuild" "once"]] 132 | :env {:production true} 133 | :aot :all 134 | :omit-source true 135 | :cljsbuild { 136 | :jar true 137 | :builds { 138 | :app { 139 | :source-paths ["env/prod/cljs"] 140 | :compiler { 141 | :optimizations :advanced 142 | :pretty-print false}}}}}}) 143 | -------------------------------------------------------------------------------- /src/cljs/typerooni/core.cljs: -------------------------------------------------------------------------------- 1 | (ns typerooni.core 2 | (:require-macros [cljs.core.async.macros :refer [go]]) 3 | (:require [reagent.core :as reagent :refer [atom]] 4 | [reagent.session :as session] 5 | [secretary.core :as secretary :include-macros true] 6 | [accountant.core :as accountant] 7 | [cljs.core.async :refer [! chan alts!]])) 8 | 9 | 10 | 11 | ;; ------------------------- 12 | ;; Views 13 | 14 | (defonce words-10ff ["or" "line" "important" "may" "life" "mountain" "went" 15 | "change" "along" "water" "through" "just" "look" "because" "than" "into" 16 | "three" "after" "does" "stop" "get" "eye" "small" "world" "carry" "play" 17 | "all" "really" "before" "don't" "family" "river" "enough" "another" "came" 18 | "number" "why" "might" "write" "must" "other" "air" "something" "even" "own" 19 | "children" "in" "keep" "saw" "kind" "see" "without" "country" "left" "night" 20 | "would" "for" "name" "being" "far" "quickly" "down" "between" "like" "miss" 21 | "to" "very" "which" "who" "seem" "some" "large" "letter" "food" "got" "never" 22 | "feet" "part" "from" "have" "away" "sound" "same" "those" "take" "great" 23 | "make" "no" "said" "day" "mean" "plant" "she" "book" "quick" "most" "hard" 24 | "way" "where" "long" "and" "say" "follow" "around" "each" "young" "animal" 25 | "he" "land" "been" "do" "car" "place" "together" "Indian" "will" "sentence" 26 | "group" "point" "different" "white" "can" "page" "them" "thought" "song" "new" 27 | "watch" "oil" "we" "ask" "two" "not" "any" "boy" "are" "list" "an" "if" "be" 28 | "city" "time" "end" "these" "picture" "show" "out" "tree" "over" "by" "run" 29 | "form" "head" "leave" "house" "you" "start" "right" "his" "that" "hear" "on" 30 | "so" "her" "how" "high" "me" "my" "sea" "study" "help" "is" "move" "your" 31 | "face" "what" "read" "it's" "their" "big" "use" "too" "below" "light" "when" 32 | "give" "near" "its" "of" "want" "later" "found" "home" "it" "both" "then" 33 | "talk" "earth" "until" "set" "only" "idea" "men" "quite" "old" "off" "took" 34 | "has" "last" "us" "school" "made" "had" "hand" "second" "tell" "come" "well" 35 | "example" "year" "girl" "much" "every" "walk" "up" "here" "spell" "need" 36 | "there" "next" "answer" "open" "him" "add" "such" "many" "learn" "was" 37 | "people" "as" "with" "mother" "word" "our" "first" "did" "America" "could" 38 | "were" "now" "back" "find" "work" "under" "still" "little" "eat" "father" 39 | "also" "state" "thing" "try" "think" "often" "paper" "turn" "above" "go" 40 | "once" "they" "call" "the" "close" "this" "grow" "one" "while" "sometimes" 41 | "story" "about" "but" "cut" "at" "few" "question" "began" "almost" "let" "put" 42 | "again" "side" "good" "four" "always" "mile" "soon" "know" "man" "should" 43 | "live" "begin" "more"]) 44 | 45 | 46 | (defonce words-aoeu ["the" "name" "of" "very" "to" "through" "and" "just" "form" "in" 47 | "much" "is" "great" "it" "think" "you" "say" "that" "help" "he" "low" "was" 48 | "line" "for" "before" "on" "turn" "are" "cause" "with" "same" "as" "mean" 49 | "differ" "his" "move" "they" "right" "be" "boy" "at" "old" "one" "too" "have" 50 | "does" "this" "tell" "from" "sentence" "or" "set" "had" "three" "by" "want" 51 | "hot" "air" "but" "well" "some" "also" "what" "play" "there" "small" "we" 52 | "end" "can" "put" "out" "home" "other" "read" "were" "hand" "all" "port" 53 | "your" "large" "when" "spell" "up" "add" "use" "even" "word" "land" "how" 54 | "here" "said" "must" "an" "big" "each" "high" "she" "such" "which" "follow" 55 | "do" "act" "their" "why" "time" "ask" "if" "men" "will" "change" "way" "went" 56 | "about" "light" "many" "kind" "then" "off" "them" "need" "would" "house" 57 | "write" "picture" "like" "try" "so" "us" "these" "again" "her" "animal" "long" 58 | "point" "make" "mother" "thing" "world" "see" "near" "him" "build" "two" 59 | "self" "has" "earth" "look" "father" "more" "head" "day" "stand" "could" "own" 60 | "go" "page" "come" "should" "did" "country" "my" "found" "sound" "answer" "no" 61 | "school" "most" "grow" "number" "study" "who" "still" "over" "learn" "know" 62 | "plant" "water" "cover" "than" "food" "call" "sun" "first" "four" "people" 63 | "thought" "may" "let" "down" "keep" "side" "eye" "been" "never" "now" "last" 64 | "find" "door" "any" "between" "new" "city" "work" "tree" "part" "cross" "take" 65 | "since" "get" "hard" "place" "start" "made" "might" "live" "story" "where" 66 | "saw" "after" "far" "back" "sea" "little" "draw" "only" "left" "round" "late" 67 | "man" "run" "year" "don't" "came" "while" "show" "press" "every" "close" 68 | "good" "night" "me" "real" "give" "life" "our" "few" "under" "stop" "open" 69 | "ten" "seem" "simple" "together" "several" "next" "vowel" "white" "toward" 70 | "children" "war" "begin" "lay" "got" "against" "walk" "pattern" "example" 71 | "slow" "ease" "center" "paper" "love" "often" "person" "always" "money" 72 | "music" "serve" "those" "appear" "both" "road" "mark" "map" "book" "science" 73 | "letter" "rule" "until" "govern" "mile" "pull" "river" "cold" "car" "notice" 74 | "feet" "voice" "care" "fall" "second" "power" "group" "town" "carry" "fine" 75 | "took" "certain" "rain" "fly" "eat" "unit" "room" "lead" "friend" "cry" 76 | "began" "dark" "idea" "machine" "fish" "note" "mountain" "wait" "north" "plan" 77 | "once" "figure" "base" "star" "hear" "box" "horse" "noun" "cut" "field" "sure" 78 | "rest" "watch" "correct" "color" "able" "face" "pound" "wood" "done" "main" 79 | "beauty" "enough" "drive" "plain" "stood" "girl" "contain" "usual" "front" 80 | "young" "teach" "ready" "week" "above" "final" "ever" "gave" "red" "green" 81 | "list" "oh" "though" "quick" "feel" "develop" "talk" "sleep" "bird" "warm" 82 | "soon" "free" "body" "minute" "dog" "strong" "family" "special" "direct" 83 | "mind" "pose" "behind" "leave" "clear" "song" "tail" "measure" "produce" 84 | "state" "fact" "product" "street" "black" "inch" "short" "lot" "numeral" 85 | "nothing" "class" "course" "wind" "stay" "question" "wheel" "happen" "full" 86 | "complete" "force" "ship" "blue" "area" "object" "half" "decide" "rock" 87 | "surface" "order" "deep" "fire" "moon" "south" "island" "problem" "foot" 88 | "piece" "yet" "told" "busy" "knew" "test" "pass" "record" "farm" "boat" "top" 89 | "common" "whole" "gold" "king" "possible" "size" "plane" "heard" "age" "best" 90 | "dry" "hour" "wonder" "better" "laugh" "true" "thousand" "during" "ago" 91 | "hundred" "ran" "am" "check" "remember" "game" "step" "shape" "early" "yes" 92 | "hold" "hot" "west" "miss" "ground" "brought" "interest" "heat" "reach" "snow" 93 | "fast" "bed" "five" "bring" "sing" "sit" "listen" "perhaps" "six" "fill" 94 | "table" "east" "travel" "weight" "less" "language" "morning" "among" "speed" 95 | "typing" "mineral" "seven" "eight" "nine" "everything" "something" "standard" 96 | "distant" "paint"]) 97 | 98 | (def keydown-input (chan)) 99 | (def keypress-input (chan)) 100 | 101 | (defn words-rows-reducer [row-length [full pending] word] 102 | (let [total-chars-of-word-and-pending (apply + (count (:word word)) (map count (map :word pending))) ; this could be fancier, taking into account space width and individual letter width 103 | adding-word-would-overflow-row (> total-chars-of-word-and-pending row-length)] 104 | (if adding-word-would-overflow-row 105 | [(conj full pending) [word]] 106 | [full (conj pending word)]))) 107 | 108 | (defn words->rows [words] 109 | (apply conj (reduce (partial words-rows-reducer 32) [[] []] words))) 110 | 111 | (defn n-random-words [n wordlist] 112 | (->> (take n (repeatedly #(rand-nth wordlist))) 113 | (map-indexed (fn [i w] {:word w :correctness "" :id i})) 114 | (into []))) 115 | 116 | (defonce history (atom [])) 117 | 118 | (defn history-json [] 119 | (clj->js @history)) 120 | 121 | (defn initial-game-state [] 122 | (let [target-words (n-random-words 500 words-10ff) 123 | target-words-rows (words->rows target-words)] 124 | {:target-words target-words 125 | :target-words-rows target-words-rows ; needs to be recalculated if the target words view changes width 126 | :words-typed [] 127 | :current-word-timestamps [] 128 | :current-word-backspace-used false 129 | :current-word 0 130 | :offset-height 0 131 | :offset-row 0 132 | :start-time 0 133 | :end-time 60000 134 | :started false 135 | :finished false 136 | :time-left 60})) 137 | 138 | (defn clear-input [input-field] (set! (.-value input-field) "")) 139 | 140 | (defn reset-game! [state input-field] 141 | (js/clearInterval (:timer-pid @state)) 142 | (reset! state (initial-game-state)) 143 | (clear-input input-field)) 144 | 145 | (defonce state (atom (initial-game-state))) 146 | 147 | (defn home-page [] 148 | [:div [:h2 "Welcome to typerooni"] 149 | [:div [:a {:href "/abou"} "go to about page"]] 150 | [:div [:a {:href "/test"} "take a test"]]]) 151 | 152 | (defn about-page [] 153 | [:div [:h2 "About typerooni"] 154 | [:div [:a {:href "/"} "go to the home page"]]]) 155 | 156 | (defn wpm [diff] 157 | (/ 12000 diff)) 158 | 159 | (defn remove-most-recent-timestamp [state] 160 | (if (not (empty? (:current-word-timestamps @state))) 161 | (do 162 | (swap! state update-in [:current-word-timestamps] pop) 163 | (swap! state assoc :current-word-backspace-used true)) 164 | (swap! state assoc :current-word-backspace-used false))) 165 | 166 | (defn reset-timestamps [state] 167 | (swap! state assoc :current-word-timestamps []) 168 | (swap! state assoc :current-word-backspace-used false)) 169 | 170 | (defn current-word-html [state] 171 | (js/document.querySelector (str "[data-word-id=\"" (:current-word @state) "\"]"))) 172 | 173 | (defn is-correct [input word] 174 | (= (apply str (butlast input)) word)) 175 | 176 | (defn save-timestamps [input state] 177 | (let [times (conj (:current-word-timestamps @state) (:timeStamp input)) 178 | time-diffs (into [] (map #(- %2 %1) times (rest times))) 179 | word (.-value (:target input)) 180 | correct (is-correct word (:word (nth (:target-words @state) (:current-word @state)))) 181 | backspace-used (:current-word-backspace-used @state) 182 | new-word {:times time-diffs :word word :correct correct :backspace-used backspace-used} 183 | correctness (if correct "correct" "incorrect") 184 | new-target (assoc (nth (:target-words @state) (:current-word @state)) :correctness correctness)] 185 | (swap! state update-in 186 | [:words-typed] conj new-word) 187 | (swap! state update-in 188 | [:target-words] assoc (:current-word @state) new-target))) 189 | 190 | (defn current-word-height [state] 191 | (try 192 | (.-height (.getBoundingClientRect (current-word-html state))) 193 | (catch :default e 194 | 0))) 195 | 196 | (defn get-current-word-tag-top [state] 197 | (try 198 | (.-top (.getBoundingClientRect (current-word-html state))) 199 | (catch :default e 200 | 0))) 201 | 202 | (defn get-current-word-parent-tag-top [state] 203 | (try 204 | (.-top (.getBoundingClientRect (.-parentElement (.-parentElement (current-word-html state))))) 205 | (catch :default e 206 | 0))) 207 | 208 | (defn current-word-top [state] 209 | (- (get-current-word-tag-top state) (get-current-word-parent-tag-top state))) 210 | 211 | (defn move-cursor [state] 212 | (swap! state update-in [:current-word] inc) 213 | (if (> (- (current-word-top state) (- (:offset-height @state))) 214 | (* 1.05 (current-word-height state))) 215 | #_(swap! state update-in [:offset-height] - (current-word-height state)) 216 | (swap! state update-in [:offset-row] inc))) 217 | 218 | (defn save-word [input state] 219 | (let [word-exists (not (clojure.string/blank? (.-value (:target input))))] 220 | (if word-exists 221 | (do (save-timestamps input state) 222 | (reset-timestamps state) 223 | (move-cursor state))))) 224 | 225 | (defn save-word-and-clear-input [input state] 226 | (save-word input state) 227 | #_(js/console.log (str (:words-typed @state))) 228 | (clear-input (:target input))) 229 | 230 | (defn save-most-recent-timestamp [input state] 231 | (let [most-recent-timestamp (:timeStamp input) 232 | is-first-letter (= 1 (count (.-value (:target input))))] 233 | (if is-first-letter (reset-timestamps state)) 234 | (swap! state update-in 235 | [:current-word-timestamps] conj most-recent-timestamp))) 236 | 237 | (defn end-game! [state] 238 | (if (and (:running @state) (not (:finished @state))) 239 | (do 240 | (swap! state assoc 241 | :running false 242 | :finished true) 243 | (js/clearInterval (:timer-pid @state)) 244 | (swap! history conj 245 | (:words-typed @state))))) 246 | 247 | (defn game-over? [state] 248 | (and (:running @state) 249 | (> (.getTime (js/Date.)) (:end-time @state)))) 250 | 251 | (defn check-if-game-over-and-update-timer [state] 252 | (if (:started @state) 253 | (if (or (game-over? state) (:finished @state)) 254 | (end-game! state) 255 | (swap! state update-in [:time-left] dec)))) 256 | 257 | (defn consume-input [keydown-input keypress-input state] 258 | (go 259 | (loop [[[input state] _] (alts! [keydown-input keypress-input])] 260 | (let [is-backspace (= (:which input) 8) 261 | is-space (= (:which input) 32) 262 | is-timer (:timer input) 263 | #_#_is-over (:finish input)] 264 | (cond 265 | is-backspace (remove-most-recent-timestamp state) 266 | is-space (save-word-and-clear-input input state) 267 | is-timer (check-if-game-over-and-update-timer state) 268 | :else (save-most-recent-timestamp input state)) 269 | (recur (alts! [keydown-input keypress-input])))))) 270 | 271 | (swap! state assoc 272 | :input-chan (consume-input keydown-input keypress-input state)) 273 | 274 | (defn event->map [e] 275 | #_(js/console.log e) 276 | {#_#_:key (.-key e) 277 | #_#_:keyCode (.-keyCode e) 278 | #_#_:charCode (.-charCode e) 279 | :which (.-which e) 280 | :timeStamp (.-timeStamp e) 281 | :target (.-target e)}) 282 | 283 | (defn start-game! [state] 284 | (let [start-time (.getTime (js/Date.)) 285 | end-time (+ start-time 60000)] 286 | (swap! state assoc 287 | :timer-pid (js/window.setInterval #(go (>! keypress-input [{:timer true} state])) 1000) 288 | :start-time start-time 289 | :end-time end-time 290 | :running true 291 | :started true)) 292 | #_(js/console.log (:timer-pid @state))) 293 | 294 | (defn keypress-func [e state] 295 | (let [time-stamp (.-timeStamp e) 296 | input (event->map e) 297 | is-space (= (:which input) 32) 298 | game-has-not-started (not (:started @state)) 299 | game-has-ended (game-over? state) 300 | game-is-over-and-new-word (and (:finished @state) is-space)] 301 | (if game-has-not-started (start-game! state)) 302 | (if game-has-ended (end-game! state)) 303 | (if game-is-over-and-new-word (clear-input (:target input))) 304 | (if (and (not (:finised @state)) (:running @state)) 305 | (go (>! keypress-input [input state]))))) 306 | 307 | (defn keydown-func [e state] 308 | (let [key-pressed {:which (.-which e)} 309 | is-backspace (= (:which key-pressed) 8) 310 | is-f5 (= (:which key-pressed) 116) 311 | is-enter (= (:which key-pressed) 13)] 312 | (if is-backspace 313 | (go (>! keydown-input [key-pressed state]))) 314 | (if is-f5 (reset-game! state (.-target e))) 315 | (if (or is-f5 is-enter) (.preventDefault e)))) 316 | 317 | (defn get-input-field-html [] 318 | (js/document.getElementById "typing-test-input")) 319 | 320 | (defn indexed-span [i word state] 321 | ^{:key i} 322 | [:span {:data-word-id (:id word) 323 | :class (str 324 | "target-word" \space 325 | (if (= (:id word) (:current-word @state)) "current") \space 326 | (:correctness ((:target-words @state) (:id word))))} 327 | (:word word)]) 328 | 329 | (defn word-view [target state] 330 | (map-indexed (fn [i word] (indexed-span i word state)) target)) 331 | 332 | (defn row->div [i row state] 333 | ^{:key i} 334 | [:div {:style {:clear "both"}} 335 | (doall (word-view row state))]) 336 | 337 | (defn typing-run-view [state] 338 | [:div {:id "target-word-view" 339 | :class (if (:finished @state) "game-over" "")} 340 | [:div {:id "target-words" 341 | :style {:top (:offset-height @state)}} 342 | (let [word-rows (take 3 (drop (:offset-row @state) (:target-words-rows @state))) 343 | words (mapcat identity word-rows)] 344 | #_(js/console.log (count words)) 345 | #_(doall (word-view words state)) 346 | (doall (map-indexed (fn [i row] (row->div i row state)) word-rows)))]]) 347 | 348 | (defn typing-run-input [] 349 | [:form {:style {:width "600px" :float "left"}} 350 | [:input#typing-test-input {:type "text" 351 | :onKeyDown (fn [e] (keydown-func e state)) 352 | :onKeyPress (fn [e] (keypress-func e state)) 353 | :autoFocus "autoFocus" 354 | :spellCheck "false" 355 | :autoCapitalize "off" 356 | :autoCorrect "off" 357 | :autoComplete "off" 358 | :style { 359 | :width "100%" 360 | :height "30px" 361 | :font-size "24" 362 | :padding-left "4px" 363 | :border-radius "4px"}}]]) 364 | 365 | (defn word->wpm [i word] 366 | (let [count (count (:times word)) 367 | sum (reduce + (:times word)) 368 | timings (:times word)] 369 | [(:word word) (js/Math.round (/ 12000 (/ sum count))) timings i])) 370 | 371 | (defn show-stats [word timings] 372 | (js/console.log (str timings))) 373 | 374 | (defn analysis [state] 375 | [:table [:tbody 376 | [:tr [:th "Word"] [:th "WPM"]] 377 | (for [[word wpm timings key] (take 10 (reverse (map-indexed word->wpm (:words-typed @state))))] 378 | ^{:key key} 379 | [:tr 380 | [:td #_{:onMouseOver (fn [e] (show-stats word timings))} [:span {:title (str timings)} word]] 381 | [:td wpm]])]]) 382 | 383 | (defn view-state [] 384 | (str (dissoc @state :target-words))) 385 | 386 | (defn game-timer [state] 387 | (cond 388 | (:finished @state) 0 389 | (not (:started @state)) 60 390 | :else (int (/ (- (:end-time @state) (.getTime (js/Date.))) 1000)))) 391 | ;; :else (:time-left @state))) 392 | 393 | (defn typing-run-timer [state] 394 | [:div {:style {:float "left"}} 395 | (let [timer (game-timer state)] 396 | [:span {:id "game-timer"} 397 | (cond 398 | (= 60 timer) "1:00" 399 | (< 9 timer 60) (str "0:" timer) 400 | :else (str "0:0" timer))])]) 401 | 402 | (defn word->wordlets-with-times [w] 403 | ;{:word "there " :times [45 63 96 58 111]} -> 404 | ; (("th" [45 "there "]) ("he" 63) ("er" 96) ("re" 58) ("e " 111)) 405 | (partition 2 2 (interleave (map #(apply str %) (partition 2 1 (:word w))) (map (fn [t] [t (:word w)]) (:times w))))) 406 | 407 | (defn wordlet-reducer [acc [wordlet timing]] 408 | (assoc acc wordlet (conj (if (acc wordlet) (acc wordlet) []) timing))) 409 | 410 | (defn wordlet-averages [[wordlet time-stamp]] 411 | (let [timings (map first time-stamp)] 412 | [wordlet 413 | (int (/ (reduce + timings) (count timings))) 414 | time-stamp])) 415 | 416 | (defn sorted-wordlets [words] 417 | ;[{:word "the " :times [23 63 88] :backspace-used false :correct true} 418 | ; {:word "there " :times [45 63 96 58 111] :backspace-used false :correct true}] 419 | ; [["th" 34] ["he" 63] ["e " 99.5] ["er" 96] ["re" 58]] 420 | 421 | ;[{:word "the " :times [23 63 88]} {:word "there " :times [45 63 96 58 111]}] 422 | ; (("th" 23) ("he" 63) ("e " 88) ("th" 45) ("he" 63) ("er" 96) ("re" 58) ("e " 111)) -> 423 | ; {"th" [[23 "the "] [45 "there "]], "he" [63 63], "e " [88 111], "er" [96], "re" [58]} -> 424 | ; [["th" 34] ["he" 63] ["e " 99.5] ["er" 96] ["re" 58]] 425 | ; [[["th" 34] 0] [["he" 63] 1] [["e " 99.5] 2] [["er" 96] 3] [["re" 58] 4]] 426 | (->> words 427 | (remove :backspace-used) 428 | (filter :correct) 429 | (mapcat word->wordlets-with-times) 430 | (reduce wordlet-reducer {}) 431 | (map wordlet-averages) 432 | (sort-by second <) 433 | (map-indexed (fn [i w] [w i])))) 434 | 435 | (defn stats [state] 436 | (let [total-words (count (:words-typed @state)) 437 | total-correct-words (count (filter :correct (:words-typed @state))) 438 | total-incorrect-words (count (remove :correct (:words-typed @state))) 439 | total-number-of-characters (->> (:words-typed @state) 440 | (filter :correct) 441 | (map :word) 442 | (map count) 443 | (reduce +)) 444 | total-words-wpm (/ total-number-of-characters 5)] 445 | [:div {:id "games-analysis"} 446 | [:span {:id "games-analysis-total-words"} 447 | (str "total words: " total-words)] 448 | [:span {:id "games-analysis-correct-words"} 449 | (str "correct words: " total-correct-words)] 450 | [:span {:id "games-analysis-incorrect-words"} 451 | (str "incorrect words: " total-incorrect-words)] 452 | [:span {:id "games-analysis-wpm"} 453 | (str "wpm: " total-words-wpm)] 454 | [:div {:id "games-analysis-wordlets"} 455 | [:table 456 | [:tbody 457 | [:tr 458 | [:th "Wordlet"] 459 | [:th {:style {:font-size "70"}} "wpm ratio"] 460 | [:th "equivalent wpm"] 461 | [:th "average ms"] 462 | [:th "Timings"] 463 | (for [[[wordlet average timings] key] (sorted-wordlets (mapcat identity @history))] 464 | ^{:key key} 465 | [:tr 466 | [:td wordlet] 467 | [:td (.toFixed (/ (/ 12000 average) total-words-wpm) 2) ] 468 | [:td (int (/ 12000 average))] 469 | [:td average] 470 | [:td (str timings)]])]]]]])) 471 | 472 | (defn test-page [] 473 | [:div {:style {:width "800px"}} 474 | #_[:div {:style {:position "absolute" 475 | :top "50px" 476 | :left "30px" 477 | :height "300px" 478 | :width "180px" 479 | :border "solid 1px black"}} (analysis state)] 480 | [:div {:font-size "2em" :width "800px"} 481 | [:span "This is a single run!"] 482 | (typing-run-view state) 483 | (typing-run-input) 484 | [typing-run-timer state] 485 | [:div {:style {:float "left" :padding-left "10px"} 486 | :onClick (fn [e] (reset-game! state (get-input-field-html)))} 487 | "reset"] 488 | (if (:finished @state) (stats state) )]]) 489 | 490 | (defn current-page [] 491 | [:div {:style {:width "800px"}} (test-page)]) 492 | 493 | ;; ------------------------- 494 | ;; Routes 495 | 496 | (secretary/defroute "/" [] 497 | (session/put! :current-page #'home-page)) 498 | 499 | (secretary/defroute "/about" [] 500 | (session/put! :current-page #'about-page)) 501 | 502 | (secretary/defroute "/test" [] 503 | (session/put! :current-page #'test-page)) 504 | 505 | ;; ------------------------- 506 | ;; Initialize app 507 | 508 | (defn mount-root [] 509 | (reagent/render [current-page] (.getElementById js/document "app"))) 510 | 511 | (defn init! [] 512 | (accountant/configure-navigation!) 513 | (accountant/dispatch-current!) 514 | (mount-root)) 515 | --------------------------------------------------------------------------------