1+ /**
2+ * "Countdown Timer" — Universal Label Studio Plugin
3+ *
4+ * Shows a progress bar in the labeling interface. When the countdown
5+ * reaches zero, behaviour depends on DISABLE_SUBMIT:
6+ * true → blocks submit via LSI.on("beforeSaveAnnotation") + overlay
7+ * false → shows a red "TIME EXCEEDED" warning in the bar (submit still works)
8+ *
9+ * Timer state persists in localStorage per project+task so refreshing
10+ * the page or navigating away does not lose progress. Timer only ticks
11+ * while the user is actively on the task page.
12+ *
13+ * In label-stream mode the plugin detects task changes via LSI.task.id
14+ * and resets automatically.
15+ *
16+ * Mount strategy (universal — works with any labeling config)
17+ * -----------------------------------------------------------
18+ * 1. MIG pagination row (next to "1 of N" and copy-prev button)
19+ * 2. Annotation panel content area (left column, above image/controls)
20+ * 3. Fallback: top of document body
21+ *
22+ * Configuration
23+ * -------------
24+ * DURATION_SEC — total time in seconds (default 300 = 5 min)
25+ * DISABLE_SUBMIT — true: block submit when expired; false: warning only
26+ *
27+ * Persistence
28+ * -----------
29+ * localStorage key: cdt_{projectId}_{taskId}
30+ * Value: remaining seconds (integer).
31+ *
32+ * Installation
33+ * ------------
34+ * Enterprise: Project → Settings → Plugins → paste this script.
35+ */
36+
37+ async function initCountdownTimer ( ) {
38+ await LSI ;
39+
40+ // ── Configuration ──────────────────────────────────────────────────────────
41+ var DURATION_SEC = 300 ;
42+ var DISABLE_SUBMIT = false ;
43+
44+ // ── Cleanup previous instance ──────────────────────────────────────────────
45+ if ( window . __cdtInterval ) clearInterval ( window . __cdtInterval ) ;
46+ if ( window . __cdtTaskPoll ) clearInterval ( window . __cdtTaskPoll ) ;
47+ if ( window . __cdtInjectTimer ) clearTimeout ( window . __cdtInjectTimer ) ;
48+ if ( window . __cdtBar ) { window . __cdtBar . remove ( ) ; window . __cdtBar = null ; }
49+ if ( window . __cdtOverlay ) { window . __cdtOverlay . remove ( ) ; window . __cdtOverlay = null ; }
50+
51+ // ── Resolve current project & task IDs ─────────────────────────────────────
52+ function getProjectId ( ) {
53+ var m = window . location . pathname . match ( / p r o j e c t s \/ ( \d + ) / ) ;
54+ return m ? m [ 1 ] : "unknown" ;
55+ }
56+
57+ function getTaskId ( ) {
58+ // LSI.task.id is the most reliable source (works in label stream)
59+ if ( LSI . task && LSI . task . id ) return String ( LSI . task . id ) ;
60+ var params = new URLSearchParams ( window . location . search ) ;
61+ return params . get ( "task" ) || "unknown" ;
62+ }
63+
64+ var projectId = getProjectId ( ) ;
65+ var taskId = getTaskId ( ) ;
66+ var storageKey = "cdt_" + projectId + "_" + taskId ;
67+
68+ // ── Restore or create remaining seconds ────────────────────────────────────
69+ var saved = localStorage . getItem ( storageKey ) ;
70+ var remaining = ( saved !== null ) ? Math . max ( 0 , parseInt ( saved , 10 ) ) : DURATION_SEC ;
71+ var BAR_ID = "cdt-timer-bar" ;
72+
73+ // ── Progress bar DOM ───────────────────────────────────────────────────────
74+ var bar = document . createElement ( "div" ) ;
75+ bar . id = BAR_ID ;
76+ bar . style . cssText =
77+ "position:relative;flex:1 1 auto;min-width:120px;height:26px;min-height:26px;" +
78+ "background:#e0e0e0;font-family:system-ui,sans-serif;border-radius:4px;" +
79+ "margin:0 8px;cursor:default;display:flex;align-items:center;justify-content:center;" ;
80+
81+ var fill = document . createElement ( "div" ) ;
82+ fill . style . cssText =
83+ "position:absolute;left:0;top:0;height:100%;width:100%;" +
84+ "transition:width 1s linear,background .6s;" +
85+ "background:#43a047;border-radius:4px;" ;
86+
87+ var label = document . createElement ( "span" ) ;
88+ label . style . cssText =
89+ "position:relative;z-index:1;font-size:12px;font-weight:700;" +
90+ "color:#fff;pointer-events:none;text-shadow:0 1px 2px rgba(0,0,0,.4);" ;
91+
92+ bar . appendChild ( fill ) ;
93+ bar . appendChild ( label ) ;
94+ window . __cdtBar = bar ;
95+
96+ // ── Inject bar — universal mount chain ───────────────────────────────────
97+ // Mounts INSIDE the annotation panel (left column), not across the full
98+ // page width. Tries anchors in order:
99+ // 1. MIG pagination row (before copy-prev btn or after pagination)
100+ // 2. Annotation panel content area (prepended as first child)
101+ // 3. Fallback: top of document body
102+ function injectBar ( ) {
103+ var old = document . getElementById ( BAR_ID ) ;
104+ if ( old && old !== bar ) old . remove ( ) ;
105+
106+ // 1. MIG pagination row — insert before copy-prev button or after pagination
107+ var copyBtn = document . getElementById ( "cpf-copy-btn" ) ;
108+ if ( copyBtn ) { copyBtn . before ( bar ) ; return ; }
109+
110+ var pagination = document . querySelector ( ".lsf-pagination" ) ;
111+ if ( pagination && pagination . parentElement ) {
112+ pagination . parentElement . appendChild ( bar ) ;
113+ return ;
114+ }
115+
116+ // 2. Annotation panel — the left column that holds image + controls.
117+ // These selectors target the annotation area only (not the side panel).
118+ var selectors = [
119+ ".lsf-main-view__annotation" ,
120+ "[class*='main-view__annotation']" ,
121+ ".lsf-main-content__task" ,
122+ "[class*='content__task']" ,
123+ ".lsf-panel__content" ,
124+ ] ;
125+ for ( var i = 0 ; i < selectors . length ; i ++ ) {
126+ var panel = document . querySelector ( selectors [ i ] ) ;
127+ if ( panel ) {
128+ bar . style . flex = "none" ;
129+ bar . style . width = "100%" ;
130+ panel . insertBefore ( bar , panel . firstChild ) ;
131+ return ;
132+ }
133+ }
134+
135+ // 3. Last resort
136+ document . body . prepend ( bar ) ;
137+ }
138+
139+ window . __cdtInjectTimer = setTimeout ( injectBar , 600 ) ;
140+
141+ // ── Helpers ────────────────────────────────────────────────────────────────
142+ function fmt ( sec ) {
143+ var m = Math . floor ( sec / 60 ) ;
144+ var s = sec % 60 ;
145+ return ( m < 10 ? "0" : "" ) + m + ":" + ( s < 10 ? "0" : "" ) + s ;
146+ }
147+
148+ function barColor ( fraction ) {
149+ if ( fraction > 0.5 ) return "#43a047" ;
150+ if ( fraction > 0.25 ) return "#fbc02d" ;
151+ if ( fraction > 0.1 ) return "#f57c00" ;
152+ return "#d32f2f" ;
153+ }
154+
155+ function updateTooltip ( ) {
156+ if ( remaining > 0 ) {
157+ var minLeft = Math . ceil ( remaining / 60 ) ;
158+ bar . title = minLeft + " min remaining.\nThe Submit button will be blocked when time runs out." ;
159+ } else {
160+ bar . title = "Time limit exceeded for this task." ;
161+ }
162+ }
163+
164+ // ── Submit blocking (only when DISABLE_SUBMIT = true) ──────────────────────
165+ var expired = remaining <= 0 ;
166+
167+ if ( DISABLE_SUBMIT ) {
168+ LSI . on ( "beforeSaveAnnotation" , function ( ) {
169+ if ( expired ) {
170+ if ( typeof Htx !== "undefined" ) {
171+ Htx . showModal ( "Time is up! You can no longer submit this task." , "error" ) ;
172+ }
173+ return false ;
174+ }
175+ return true ;
176+ } ) ;
177+ }
178+
179+ // ── Task change detection (label stream) ───────────────────────────────────
180+ // Poll every 2s: if LSI.task.id changed, re-init the whole plugin.
181+ window . __cdtTaskPoll = setInterval ( function ( ) {
182+ var currentTaskId = getTaskId ( ) ;
183+ if ( currentTaskId !== taskId && currentTaskId !== "unknown" ) {
184+ console . log ( "[CountdownTimer] Task changed: " + taskId + " → " + currentTaskId + ". Re-initializing." ) ;
185+ initCountdownTimer ( ) ;
186+ }
187+ } , 2000 ) ;
188+
189+ // ── Tick ────────────────────────────────────────────────────────────────────
190+ function tick ( ) {
191+ remaining -- ;
192+ if ( remaining < 0 ) remaining = 0 ;
193+ localStorage . setItem ( storageKey , String ( remaining ) ) ;
194+
195+ var fraction = remaining / DURATION_SEC ;
196+ fill . style . width = ( fraction * 100 ) . toFixed ( 2 ) + "%" ;
197+ fill . style . background = barColor ( fraction ) ;
198+ label . textContent = fmt ( remaining ) ;
199+ updateTooltip ( ) ;
200+
201+ if ( remaining <= 0 && ! expired ) {
202+ expired = true ;
203+ clearInterval ( window . __cdtInterval ) ;
204+ window . __cdtInterval = null ;
205+ onExpired ( ) ;
206+ }
207+ }
208+
209+ // ── Expired ────────────────────────────────────────────────────────────────
210+ function onExpired ( ) {
211+ if ( DISABLE_SUBMIT ) {
212+ onExpiredBlocking ( ) ;
213+ } else {
214+ onExpiredWarning ( ) ;
215+ }
216+ }
217+
218+ // Warning-only mode: red bar with exceeded message, submit still works
219+ function onExpiredWarning ( ) {
220+ fill . style . width = "100%" ;
221+ fill . style . background = "#d32f2f" ;
222+ fill . style . transition = "none" ;
223+ label . textContent = "TIME EXCEEDED" ;
224+ label . style . fontSize = "11px" ;
225+ label . style . letterSpacing = "0.05em" ;
226+ bar . style . animation = "cdt-pulse 2s ease-in-out 3" ;
227+
228+ var style = document . getElementById ( "cdt-pulse-style" ) ;
229+ if ( ! style ) {
230+ style = document . createElement ( "style" ) ;
231+ style . id = "cdt-pulse-style" ;
232+ style . textContent = "@keyframes cdt-pulse{0%,100%{opacity:1}50%{opacity:.5}}" ;
233+ document . head . appendChild ( style ) ;
234+ }
235+
236+ console . log ( "[CountdownTimer] Time exceeded — warning shown (submit NOT blocked)." ) ;
237+ }
238+
239+ // Blocking mode: overlay + submit disabled
240+ function onExpiredBlocking ( ) {
241+ bar . style . display = "none" ;
242+
243+ var overlay = document . createElement ( "div" ) ;
244+ overlay . style . cssText =
245+ "position:fixed;top:0;left:0;width:100%;height:100%;z-index:100000;" +
246+ "background:rgba(0,0,0,.55);display:flex;align-items:center;" +
247+ "justify-content:center;font-family:system-ui,sans-serif;" ;
248+
249+ var box = document . createElement ( "div" ) ;
250+ box . style . cssText =
251+ "background:#fff;border-radius:12px;padding:32px 48px;text-align:center;" +
252+ "box-shadow:0 8px 32px rgba(0,0,0,.3);max-width:420px;" ;
253+
254+ var icon = document . createElement ( "div" ) ;
255+ icon . textContent = "\u23F0" ;
256+ icon . style . cssText = "font-size:48px;margin-bottom:12px;" ;
257+
258+ var title = document . createElement ( "div" ) ;
259+ title . textContent = "Time is up!" ;
260+ title . style . cssText = "font-size:22px;font-weight:800;color:#d32f2f;margin-bottom:8px;" ;
261+
262+ var msg = document . createElement ( "div" ) ;
263+ msg . textContent = "Your time has expired. You can no longer submit this task." ;
264+ msg . style . cssText = "font-size:14px;color:#555;line-height:1.5;margin-bottom:20px;" ;
265+
266+ var okBtn = document . createElement ( "button" ) ;
267+ okBtn . textContent = "OK" ;
268+ okBtn . style . cssText =
269+ "background:#1890ff;color:#fff;border:none;border-radius:6px;" +
270+ "padding:8px 32px;font-size:14px;font-weight:600;cursor:pointer;" +
271+ "box-shadow:0 2px 6px rgba(0,0,0,.15);transition:background .15s;" ;
272+ okBtn . addEventListener ( "mouseenter" , function ( ) { okBtn . style . background = "#1070d0" ; } ) ;
273+ okBtn . addEventListener ( "mouseleave" , function ( ) { okBtn . style . background = "#1890ff" ; } ) ;
274+ okBtn . addEventListener ( "click" , function ( ) {
275+ overlay . remove ( ) ;
276+ window . __cdtOverlay = null ;
277+ } ) ;
278+
279+ box . appendChild ( icon ) ;
280+ box . appendChild ( title ) ;
281+ box . appendChild ( msg ) ;
282+ box . appendChild ( okBtn ) ;
283+ overlay . appendChild ( box ) ;
284+ document . body . appendChild ( overlay ) ;
285+ window . __cdtOverlay = overlay ;
286+
287+ console . log ( "[CountdownTimer] Time expired — submit disabled." ) ;
288+ }
289+
290+ // ── Initial render ─────────────────────────────────────────────────────────
291+ if ( expired ) {
292+ fill . style . width = "0%" ;
293+ fill . style . background = barColor ( 0 ) ;
294+ label . textContent = "00:00" ;
295+ setTimeout ( function ( ) { onExpired ( ) ; } , 700 ) ;
296+ } else {
297+ var initFraction = remaining / DURATION_SEC ;
298+ fill . style . width = ( initFraction * 100 ) . toFixed ( 2 ) + "%" ;
299+ fill . style . background = barColor ( initFraction ) ;
300+ label . textContent = fmt ( remaining ) ;
301+ updateTooltip ( ) ;
302+ window . __cdtInterval = setInterval ( tick , 1000 ) ;
303+ }
304+
305+ console . log (
306+ "[CountdownTimer] Plugin loaded. Key=" + storageKey +
307+ ", remaining=" + fmt ( remaining ) +
308+ ", DISABLE_SUBMIT=" + DISABLE_SUBMIT +
309+ ( expired ? " (EXPIRED)" : "" ) + "."
310+ ) ;
311+ }
312+
313+ initCountdownTimer ( ) ;
0 commit comments