Skip to content

Commit 6588b09

Browse files
committed
feat: BROS-977: Countdown timer
1 parent d624df9 commit 6588b09

3 files changed

Lines changed: 321 additions & 0 deletions

File tree

src/countdown-timer/data.json

Whitespace-only changes.

src/countdown-timer/plugin.js

Lines changed: 313 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,313 @@
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(/projects\/(\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();

src/countdown-timer/view.xml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<View>
2+
<Image name="image" value="$image"/>
3+
<Choices name="choice" toName="image">
4+
<Choice value="Adult content"/>
5+
<Choice value="Weapons" />
6+
<Choice value="Violence" />
7+
</Choices>
8+
</View>

0 commit comments

Comments
 (0)