Last active
April 4, 2024 22:23
-
-
Save guiseek/ca631907bf1e5f3769fe8192636bffdb to your computer and use it in GitHub Desktop.
Idle Detection
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export interface IdleConfig { | |
maxIdle: number; | |
warnPeriod?: number; | |
scope?: Node; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import type { IdleConfig } from "./idle-config"; | |
import { | |
merge, | |
filter, | |
Subject, | |
interval, | |
fromEvent, | |
takeUntil, | |
debounceTime, | |
BehaviorSubject, | |
Subscription, | |
} from "rxjs"; | |
export class IdleObserver { | |
#idled; | |
#warned; | |
#active; | |
#counter; | |
#progress; | |
#config: Required<IdleConfig>; | |
get config() { | |
return this.#config; | |
} | |
get state() { | |
const idled = this.#idled.value; | |
const warned = this.#warned.value; | |
const active = this.#active.observed; | |
const counter = this.#counter.value; | |
const progress = this.#progress.value; | |
return { idled, warned, active, counter, progress }; | |
} | |
#subs = new Subscription(); | |
constructor(config: IdleConfig) { | |
this.#config = this.#mergeConfig(config); | |
this.#idled = new BehaviorSubject(false); | |
this.#active = new BehaviorSubject(false); | |
this.#warned = new BehaviorSubject(false); | |
this.#counter = new BehaviorSubject(config.maxIdle); | |
this.#progress = new BehaviorSubject(0); | |
this.#active = new Subject<boolean>(); | |
} | |
set onwarn(callback: (value: boolean) => void) { | |
this.#subs.add(this.#warned.asObservable().subscribe(callback)); | |
} | |
set onidle(callback: (value: boolean) => void) { | |
this.#subs.add(this.#idled.asObservable().subscribe(callback)); | |
} | |
set onactive(callback: (value: boolean) => void) { | |
this.#subs.add(this.#active.asObservable().subscribe(callback)); | |
} | |
set oncounter(callback: (value: number) => void) { | |
this.#subs.add(this.#counter.asObservable().subscribe(callback)); | |
} | |
set onprogress(callback: (value: number) => void) { | |
this.#subs.add(this.#progress.asObservable().subscribe(callback)); | |
} | |
warn(callback: (value: boolean) => void) { | |
return this.#warned.asObservable().subscribe(callback); | |
} | |
idle(callback: (value: boolean) => void) { | |
return this.#idled.asObservable().subscribe(callback); | |
} | |
active(callback: (value: boolean) => void) { | |
return this.#active.asObservable().subscribe(callback); | |
} | |
counter(callback: (value: number) => void) { | |
return this.#counter.asObservable().subscribe(callback); | |
} | |
progress(callback: (value: number) => void) { | |
return this.#progress.asObservable().subscribe(callback); | |
} | |
subscribe = () => { | |
const $interval = this.#subscribe(); | |
const key$ = fromEvent(this.config.scope, "keydown"); | |
const mouse$ = fromEvent(this.config.scope, "mousemove"); | |
const merge$ = merge(key$, mouse$).pipe( | |
takeUntil(this.#active), | |
debounceTime(10) | |
); | |
const $merge = merge$.subscribe(this.subscribe); | |
const unsubscribe = () => { | |
this.#active.next(false); | |
$merge.unsubscribe(); | |
$interval.unsubscribe(); | |
}; | |
return { unsubscribe }; | |
}; | |
setConfig(value: Partial<IdleConfig>) { | |
this.#config = { ...this.config, ...value }; | |
return this; | |
} | |
#subscribe = () => { | |
this.#resetValues(); | |
const interval$ = interval(1000).pipe( | |
takeUntil(this.#active), | |
filter(() => !this.#idled.value) | |
); | |
return interval$.subscribe(this.#update); | |
}; | |
#resetValues = () => { | |
this.#active.next(true); | |
this.#progress.next(0); | |
this.#idled.next(false); | |
this.#warned.next(false); | |
this.#counter.next(this.config.maxIdle); | |
}; | |
#update = (value: number) => { | |
const idled = this.#idleExceed(value); | |
if (idled !== this.#idled.value) { | |
this.#idled.next(idled); | |
} | |
const warned = this.#warnExceed(value); | |
if (warned !== this.#warned.value) { | |
this.#warned.next(warned); | |
} | |
this.#counter.next(this.#getCounter(value)); | |
this.#progress.next(this.#getProgress(value)); | |
}; | |
#getCounter(value: number) { | |
return this.config.maxIdle - value; | |
} | |
#getProgress(value: number) { | |
const percent = (value / this.config.maxIdle) * 100; | |
return parseInt(String(percent), 10); | |
} | |
#idleExceed(value: number) { | |
return value >= this.config.maxIdle; | |
} | |
#warnExceed(value: number) { | |
return value >= this.config.maxIdle - this.config.warnPeriod; | |
} | |
#mergeConfig(value: IdleConfig): Required<IdleConfig> { | |
return { warnPeriod: 20, scope: document.body, ...value }; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="pt-br"> | |
<head> | |
<meta charset="utf-8" /> | |
<link rel="icon" type="image/svg+xml" href="/ts.svg" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<title>Idle Observer</title> | |
</head> | |
<body> | |
<header> | |
<img src="ts.svg" width="64" alt="Vite" /> | |
</header> | |
<main> | |
<section id="section"> | |
<h2>Counter: <code id="counter"></code></h2> | |
<h2>Progress: <code id="progress"></code>%</h2> | |
<h2>Active: <code id="active"></code></h2> | |
<h2>Warned: <code id="warned"></code></h2> | |
<h2>Idled: <code id="idled"></code></h2> | |
</section> | |
</main> | |
<form id="form"> | |
<label> | |
Max idle time | |
<input type="number" name="maxIdle" value="80" /> | |
</label> | |
<label> | |
Warning period | |
<input type="number" name="warnPeriod" value="20" /> | |
</label> | |
<button type="button" id="activate">Activate</button> | |
<button type="button" id="deactivate">Deactivate</button> | |
</form> | |
<script type="module" src="/src/main.ts"></script> | |
</body> | |
</html> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { IdleObserver } from "./lib/idle-observer"; | |
import { parse } from "./utils/parse"; | |
import "./style.css"; | |
const idleObserver = new IdleObserver({ | |
scope: section, | |
maxIdle: 80, | |
warnPeriod: 20, | |
}); | |
idleObserver.onidle = (value) => (idled.textContent = String(value)); | |
idleObserver.onwarn = (value) => (warned.textContent = String(value)); | |
idleObserver.onactive = (value) => (active.textContent = String(value)); | |
idleObserver.oncounter = (value) => (counter.textContent = String(value)); | |
idleObserver.onprogress = (value) => (progress.textContent = String(value)); | |
let detector$: { unsubscribe(): void }; | |
form.onchange = (ev) => { | |
ev.preventDefault(); | |
detector$ = idleObserver.setConfig(parse(form)).subscribe(); | |
}; | |
activate.onclick = () => { | |
detector$ = idleObserver.subscribe(); | |
}; | |
deactivate.onclick = () => { | |
detector$.unsubscribe(); | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
export const parse = <T extends object>(form: HTMLFormElement) => { | |
return Object.fromEntries(new FormData(form).entries()) as T; | |
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
:root { | |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
line-height: 1.5; | |
font-weight: 400; | |
color-scheme: light dark; | |
color: rgba(179, 174, 174, 0.87); | |
background-color: #242424; | |
font-synthesis: none; | |
text-rendering: optimizeLegibility; | |
-webkit-font-smoothing: antialiased; | |
-moz-osx-font-smoothing: grayscale; | |
} | |
a { | |
font-weight: 500; | |
color: #646cff; | |
text-decoration: inherit; | |
} | |
a:hover { | |
color: #535bf2; | |
} | |
code { | |
font-family: monospace; | |
white-space: pre-wrap; | |
font-size: 1.4rem; | |
color: white; | |
} | |
body { | |
margin: 0; | |
display: flex; | |
flex-direction: column; | |
justify-content: center; | |
align-items: center; | |
min-width: 320px; | |
min-height: 100vh; | |
} | |
h1 { | |
font-size: 3.2em; | |
line-height: 1.1; | |
} | |
form { | |
gap: 16px; | |
} | |
form, | |
label { | |
display: flex; | |
flex-direction: column; | |
} | |
input { | |
padding: 8px 16px; | |
border-radius: 4px; | |
border: 1px solid rgb(255, 255, 255, 0.2); | |
transition: border-color 0.25s; | |
} | |
input:hover, | |
input:active, | |
input:focus { | |
border-color: #646cff; | |
} | |
#section { | |
margin: 60px; | |
width: 280px; | |
padding: 16px 32px; | |
border-radius: 24px; | |
transition: background-color 0.4s, border-color 0.8s; | |
border: 2px dashed rgb(255, 255, 255, 0.1); | |
} | |
#section:hover { | |
background-color: rgb(255, 255, 255, 0.01); | |
border-color: rgb(255, 255, 255, 0.3); | |
} | |
#app { | |
max-width: 1280px; | |
margin: 0 auto; | |
padding: 2rem; | |
text-align: center; | |
} | |
img { | |
width: 80px; | |
border-radius: 12px; | |
box-shadow: 2px 2px 8px 0px rgba(0, 0, 0, 0.4); | |
} | |
button { | |
border-radius: 8px; | |
border: 1px solid transparent; | |
padding: 0.6em 1.2em; | |
font-size: 1em; | |
font-weight: 500; | |
font-family: inherit; | |
background-color: #1a1a1a; | |
cursor: pointer; | |
transition: border-color 0.25s; | |
} | |
button:hover { | |
border-color: #646cff; | |
} | |
button:focus, | |
button:focus-visible { | |
outline: 4px auto -webkit-focus-ring-color; | |
} | |
@media (prefers-color-scheme: light) { | |
:root { | |
color: #213547; | |
background-color: #ffffff; | |
} | |
a:hover { | |
color: #747bff; | |
} | |
button { | |
background-color: #f9f9f9; | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/// <reference types="vite/client" /> | |
declare const section: HTMLElement | |
declare const counter: HTMLElement | |
declare const progress: HTMLElement | |
declare const active: HTMLElement | |
declare const warned: HTMLElement | |
declare const idled: HTMLElement | |
declare const activate: HTMLButtonElement | |
declare const deactivate: HTMLButtonElement |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment