updateday 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +109 -2
  3. package/assets/hourglass/10m.png +0 -0
  4. package/assets/hourglass/12h.png +0 -0
  5. package/assets/hourglass/15m.png +0 -0
  6. package/assets/hourglass/1h.png +0 -0
  7. package/assets/hourglass/24h.png +0 -0
  8. package/assets/hourglass/2h.png +0 -0
  9. package/assets/hourglass/30m.png +0 -0
  10. package/assets/hourglass/3h.png +0 -0
  11. package/assets/hourglass/45m.png +0 -0
  12. package/assets/hourglass/4h.png +0 -0
  13. package/assets/hourglass/5h.png +0 -0
  14. package/assets/hourglass/5m.png +0 -0
  15. package/assets/hourglass/6h.png +0 -0
  16. package/assets/hourglass/default.png +0 -0
  17. package/assets/hourglass/now.png +0 -0
  18. package/assets/hourglass-styles/blocky.png +0 -0
  19. package/assets/hourglass-styles/classic.png +0 -0
  20. package/assets/hourglass-styles/glow.png +0 -0
  21. package/assets/hourglass-styles/outline.png +0 -0
  22. package/assets/hourglass-styles/pixel.png +0 -0
  23. package/assets/hourglass-styles/rounded.png +0 -0
  24. package/bin/updateday.js +5 -0
  25. package/dist/bundle.js +54 -0
  26. package/dist/cli.js +254 -0
  27. package/dist/config.js +261 -0
  28. package/dist/doctor.js +119 -0
  29. package/dist/experiments.js +349 -0
  30. package/dist/gamePage.js +143 -0
  31. package/dist/gifts.js +55 -0
  32. package/dist/history.js +68 -0
  33. package/dist/icons.js +216 -0
  34. package/dist/labels.js +64 -0
  35. package/dist/mcp.js +302 -0
  36. package/dist/moderation.js +86 -0
  37. package/dist/notify.js +72 -0
  38. package/dist/openCloud.js +211 -0
  39. package/dist/playerData.js +109 -0
  40. package/dist/preflight.js +202 -0
  41. package/dist/runner.js +360 -0
  42. package/dist/schedule.js +162 -0
  43. package/dist/server.js +929 -0
  44. package/dist/state.js +29 -0
  45. package/dist/swap.js +196 -0
  46. package/dist/zip.js +82 -0
  47. package/docs/AGENTS.md +123 -0
  48. package/docs/COVERAGE.md +50 -0
  49. package/docs/GUIDE.md +288 -0
  50. package/docs/hourglass-styles.png +0 -0
  51. package/docs/screenshot.png +0 -0
  52. package/example/multi-game.json +16 -0
  53. package/example/updateday.json +37 -0
  54. package/package.json +63 -5
  55. package/roblox/UpdateDayBanner.client.luau +159 -0
  56. package/roblox/UpdateDayCountdown.server.luau +48 -0
  57. package/roblox/UpdateDayGifts.luau +119 -0
  58. package/roblox/UpdateDayListener.luau +43 -0
  59. package/templates/github-actions/updateday.yml +35 -0
  60. package/ui/core.js +325 -0
  61. package/ui/countdown.js +775 -0
  62. package/ui/events.js +249 -0
  63. package/ui/gifts.js +215 -0
  64. package/ui/history.js +156 -0
  65. package/ui/index.html +530 -0
  66. package/ui/jsonEditor.js +417 -0
  67. package/ui/page.js +94 -0
  68. package/ui/players.js +256 -0
  69. package/ui/run.js +391 -0
  70. package/ui/sale.js +366 -0
  71. package/ui/settings.js +414 -0
  72. package/ui/shell.js +193 -0
@@ -0,0 +1,119 @@
1
+ local DataStoreService = game:GetService("DataStoreService")
2
+ local HttpService = game:GetService("HttpService")
3
+ local MessagingService = game:GetService("MessagingService")
4
+ local Players = game:GetService("Players")
5
+
6
+ local STORE_NAME = "UpdateDayGifts"
7
+ local TOPIC = "UpdateDayGifts"
8
+ local RETRY_DELAY = 5
9
+
10
+ local UpdateDayGifts = {}
11
+
12
+ local store = DataStoreService:GetDataStore(STORE_NAME)
13
+ local handlers = {}
14
+ local busy = {}
15
+
16
+ local function giftsOf(value)
17
+ if typeof(value) == "table" and typeof(value.gifts) == "table" then
18
+ return value.gifts
19
+ end
20
+ return {}
21
+ end
22
+
23
+ local function claimAll(userId)
24
+ local claimed = {}
25
+ local ok, err = pcall(store.UpdateAsync, store, tostring(userId), function(current)
26
+ claimed = giftsOf(current)
27
+ if #claimed == 0 then
28
+ return nil
29
+ end
30
+ return { gifts = {} }
31
+ end)
32
+ if not ok then
33
+ warn("UpdateDayGifts could not read gifts:", err)
34
+ return {}
35
+ end
36
+ return claimed
37
+ end
38
+
39
+ local function putBack(userId, gifts)
40
+ if #gifts == 0 then
41
+ return
42
+ end
43
+ local ok, err = pcall(store.UpdateAsync, store, tostring(userId), function(current)
44
+ local merged = giftsOf(current)
45
+ for _, gift in gifts do
46
+ table.insert(merged, gift)
47
+ end
48
+ return { gifts = merged }
49
+ end)
50
+ if not ok then
51
+ warn("UpdateDayGifts could not put gifts back:", err, HttpService:JSONEncode(gifts))
52
+ end
53
+ end
54
+
55
+ local function give(player, gift)
56
+ local handler = handlers[gift.category]
57
+ if not handler then
58
+ return false
59
+ end
60
+ local ok, result = pcall(handler, player, gift)
61
+ if not ok then
62
+ warn("UpdateDayGifts handler failed for", gift.category, result)
63
+ return false
64
+ end
65
+ return result ~= false
66
+ end
67
+
68
+ local function deliver(player)
69
+ if busy[player] then
70
+ return
71
+ end
72
+ busy[player] = true
73
+ local failed = {}
74
+ for _, gift in claimAll(player.UserId) do
75
+ if not give(player, gift) then
76
+ table.insert(failed, gift)
77
+ end
78
+ end
79
+ putBack(player.UserId, failed)
80
+ busy[player] = nil
81
+ end
82
+
83
+ local function onMessage(message)
84
+ local ok, payload = pcall(HttpService.JSONDecode, HttpService, message.Data)
85
+ if not ok or typeof(payload) ~= "table" then
86
+ return
87
+ end
88
+ local player = Players:GetPlayerByUserId(tonumber(payload.userId) or 0)
89
+ if player then
90
+ deliver(player)
91
+ end
92
+ end
93
+
94
+ local function subscribe()
95
+ local ok, err = pcall(MessagingService.SubscribeAsync, MessagingService, TOPIC, onMessage)
96
+ if not ok then
97
+ warn("UpdateDayGifts could not subscribe, retrying:", err)
98
+ task.delay(RETRY_DELAY, subscribe)
99
+ end
100
+ end
101
+
102
+ function UpdateDayGifts.OnGift(category, handler)
103
+ handlers[category] = handler
104
+ end
105
+
106
+ function UpdateDayGifts.Start()
107
+ Players.PlayerAdded:Connect(function(player)
108
+ task.spawn(deliver, player)
109
+ end)
110
+ Players.PlayerRemoving:Connect(function(player)
111
+ busy[player] = nil
112
+ end)
113
+ for _, player in Players:GetPlayers() do
114
+ task.spawn(deliver, player)
115
+ end
116
+ task.spawn(subscribe)
117
+ end
118
+
119
+ return UpdateDayGifts
@@ -0,0 +1,43 @@
1
+ local MessagingService = game:GetService("MessagingService")
2
+ local HttpService = game:GetService("HttpService")
3
+
4
+ local DEFAULT_TOPIC = "UpdateDay"
5
+
6
+ local UpdateDayListener = {}
7
+
8
+ local listeners = {}
9
+
10
+ local function decode(message)
11
+ local ok, payload = pcall(HttpService.JSONDecode, HttpService, message.Data)
12
+ if ok and typeof(payload) == "table" then
13
+ return payload
14
+ end
15
+ return nil
16
+ end
17
+
18
+ local function onMessage(message)
19
+ local payload = decode(message)
20
+ if not payload or typeof(payload.updateAt) ~= "string" then
21
+ return
22
+ end
23
+ local updateAt = DateTime.fromIsoDate(payload.updateAt)
24
+ if not updateAt then
25
+ return
26
+ end
27
+ for _, callback in listeners do
28
+ task.spawn(callback, updateAt.UnixTimestamp, payload.mark)
29
+ end
30
+ end
31
+
32
+ function UpdateDayListener.OnWarning(callback)
33
+ table.insert(listeners, callback)
34
+ end
35
+
36
+ function UpdateDayListener.Start(topic)
37
+ local ok, err = pcall(MessagingService.SubscribeAsync, MessagingService, topic or DEFAULT_TOPIC, onMessage)
38
+ if not ok then
39
+ warn("UpdateDayListener failed to subscribe:", err)
40
+ end
41
+ end
42
+
43
+ return UpdateDayListener
@@ -0,0 +1,35 @@
1
+ name: UpdateDay
2
+
3
+ on:
4
+ schedule:
5
+ - cron: "*/5 * * * *"
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: write
10
+
11
+ concurrency:
12
+ group: updateday
13
+ cancel-in-progress: false
14
+
15
+ jobs:
16
+ tick:
17
+ runs-on: ubuntu-latest
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+ - name: Fonts for translated countdown labels
21
+ run: sudo apt-get update && sudo apt-get install -y --no-install-recommends fonts-noto-core fonts-noto-cjk
22
+ - uses: actions/setup-node@v4
23
+ with:
24
+ node-version: 24
25
+ - run: npx --yes updateday@latest tick updateday.json
26
+ env:
27
+ UPDATEDAY_API_KEY: ${{ secrets.UPDATEDAY_API_KEY }}
28
+ UPDATEDAY_DISCORD_WEBHOOK: ${{ secrets.UPDATEDAY_DISCORD_WEBHOOK }}
29
+ - name: Save progress
30
+ run: |
31
+ git config user.name "updateday"
32
+ git config user.email "updateday@users.noreply.github.com"
33
+ git add -f updateday*.state.json updateday*.original-icon.png updateday-history.jsonl updateday-icons 2>/dev/null || true
34
+ git diff --cached --quiet || git commit -m "UpdateDay progress"
35
+ git push
package/ui/core.js ADDED
@@ -0,0 +1,325 @@
1
+ const UD = {
2
+ draft: null,
3
+ schedule: null,
4
+ revision: 0,
5
+ selected: 0,
6
+ view: "countdown",
7
+ dirty: false,
8
+ configPath: "",
9
+ views: {},
10
+ listeners: new Set(),
11
+ };
12
+
13
+ const ALL_MARKS = ["24h", "12h", "6h", "5h", "4h", "3h", "2h", "1h", "45m", "30m", "15m", "10m", "5m"];
14
+ const UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
15
+ const PUSH_DEBOUNCE_MS = 350;
16
+ const TOAST_MS = 4200;
17
+ const THEME_KEY = "updateday-theme";
18
+ const DESKTOP_ALERTS_KEY = "updateday-desktop-alerts";
19
+ const LOCALE = "en-US";
20
+
21
+ let pushTimer = null;
22
+
23
+ const $ = (id) => document.getElementById(id);
24
+
25
+ function h(text) {
26
+ return String(text ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
27
+ }
28
+
29
+ async function api(method, path, body, isBinary) {
30
+ const options = { method, headers: {} };
31
+ if (body !== undefined) {
32
+ options.body = isBinary ? body : JSON.stringify(body);
33
+ options.headers["content-type"] = isBinary ? "application/octet-stream" : "application/json";
34
+ }
35
+ const response = await fetch(path, options);
36
+ const data = await response.json().catch(() => ({}));
37
+ if (!response.ok) {
38
+ throw new Error(data.error || `Request failed (${response.status})`);
39
+ }
40
+ return data;
41
+ }
42
+
43
+ function offsetMs(mark) {
44
+ const match = /^(\d+(?:\.\d+)?)([smhd])$/.exec(mark);
45
+ return match ? Number(match[1]) * UNIT_MS[match[2]] : 0;
46
+ }
47
+
48
+ function toLocalInput(iso) {
49
+ const date = new Date(iso);
50
+ if (Number.isNaN(date.getTime())) {
51
+ return "";
52
+ }
53
+ return new Date(date.getTime() - date.getTimezoneOffset() * 6e4).toISOString().slice(0, 16);
54
+ }
55
+
56
+ function fromLocalInput(value) {
57
+ const date = new Date(value);
58
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
59
+ }
60
+
61
+ function formatWhen(date) {
62
+ return new Date(date).toLocaleString(LOCALE, { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
63
+ }
64
+
65
+ function formatTime(date) {
66
+ return new Date(date).toLocaleTimeString(LOCALE, { hour: "numeric", minute: "2-digit", second: "2-digit" });
67
+ }
68
+
69
+ function formatSpan(ms) {
70
+ const total = Math.max(0, Math.round(ms / 1000));
71
+ const days = Math.floor(total / 86400);
72
+ const hours = Math.floor((total % 86400) / 3600);
73
+ const minutes = Math.floor((total % 3600) / 60);
74
+ if (days > 0) {
75
+ return `${days}d ${hours}h`;
76
+ }
77
+ if (hours > 0) {
78
+ return `${hours}h ${minutes}m`;
79
+ }
80
+ if (minutes > 0) {
81
+ return `${minutes}m`;
82
+ }
83
+ return `${total}s`;
84
+ }
85
+
86
+ function clock(ms) {
87
+ const total = Math.max(0, Math.floor(ms / 1000));
88
+ const pad = (n) => String(n).padStart(2, "0");
89
+ return `${pad(Math.floor(total / 3600))}:${pad(Math.floor((total % 3600) / 60))}:${pad(total % 60)}`;
90
+ }
91
+
92
+ function stageName(mark) {
93
+ if (mark === "now") {
94
+ return "NOW!";
95
+ }
96
+ const match = /^(\d+)([mh])$/.exec(mark);
97
+ if (!match) {
98
+ return mark.toUpperCase();
99
+ }
100
+ const n = Number(match[1]);
101
+ return `${n} ${match[2] === "h" ? "hour" : "min"}${n === 1 ? "" : "s"}`;
102
+ }
103
+
104
+ function currentGame() {
105
+ return UD.draft?.games?.[UD.selected] ?? null;
106
+ }
107
+
108
+ function scheduledGame(index = UD.selected) {
109
+ return UD.schedule?.games?.[index] ?? null;
110
+ }
111
+
112
+ function gameUpdateAt(game) {
113
+ return new Date(game?.updateAt ?? UD.draft.updateAt);
114
+ }
115
+
116
+ function countdown() {
117
+ if (UD.draft.countdown === false) {
118
+ return null;
119
+ }
120
+ UD.draft.countdown = UD.draft.countdown || {};
121
+ return UD.draft.countdown;
122
+ }
123
+
124
+ function gameQuery() {
125
+ return `game=${UD.selected}`;
126
+ }
127
+
128
+ function iconUrl(mark, index = UD.selected) {
129
+ return `/api/icon?game=${index}&mark=${encodeURIComponent(mark)}&r=${UD.revision}`;
130
+ }
131
+
132
+ function onChange(listener) {
133
+ UD.listeners.add(listener);
134
+ }
135
+
136
+ function notify() {
137
+ for (const listener of UD.listeners) {
138
+ listener();
139
+ }
140
+ }
141
+
142
+ function markDirty() {
143
+ UD.dirty = true;
144
+ renderSaveState();
145
+ clearTimeout(pushTimer);
146
+ pushTimer = setTimeout(pushDraft, PUSH_DEBOUNCE_MS);
147
+ }
148
+
149
+ async function pushDraft() {
150
+ try {
151
+ const result = await api("PUT", "/api/draft", UD.draft);
152
+ UD.revision = result.revision;
153
+ await refreshSchedule();
154
+ } catch (error) {
155
+ toast(error.message, "danger");
156
+ }
157
+ }
158
+
159
+ async function refreshSchedule() {
160
+ UD.schedule = await api("GET", "/api/schedule");
161
+ renderSaveState();
162
+ notify();
163
+ }
164
+
165
+ function renderSaveState() {
166
+ const status = $("saveStatus");
167
+ const button = $("saveBtn");
168
+ if (!status || !button) {
169
+ return;
170
+ }
171
+ const error = UD.schedule?.error;
172
+ status.dataset.state = error ? "error" : UD.dirty ? "dirty" : "saved";
173
+ status.textContent = error ? "Fix the highlighted problem to save" : UD.dirty ? "Unsaved changes" : "All changes saved";
174
+ button.disabled = !UD.dirty || Boolean(error);
175
+ }
176
+
177
+ async function save() {
178
+ try {
179
+ if (pushTimer) {
180
+ clearTimeout(pushTimer);
181
+ pushTimer = null;
182
+ await pushDraft();
183
+ }
184
+ const result = await api("POST", "/api/save");
185
+ UD.dirty = false;
186
+ renderSaveState();
187
+ toast(result.running ? "Saved. Stop and start the run to use these changes." : "Saved", "success");
188
+ } catch (error) {
189
+ toast(error.message, "danger");
190
+ }
191
+ }
192
+
193
+ function toast(message, kind = "info") {
194
+ const region = $("toasts");
195
+ const item = document.createElement("div");
196
+ item.className = `toast toast-${kind}`;
197
+ item.setAttribute("role", kind === "danger" ? "alert" : "status");
198
+ item.innerHTML = `<span>${h(message)}</span><button class="icon-btn" aria-label="Dismiss">✕</button>`;
199
+ item.querySelector("button").addEventListener("click", () => item.remove());
200
+ region.appendChild(item);
201
+ setTimeout(() => item.remove(), kind === "danger" ? TOAST_MS * 2 : TOAST_MS);
202
+ }
203
+
204
+ function dialog({ title, body, confirmText = "Confirm", cancelText = "Cancel", danger = false, html = false }) {
205
+ return new Promise((resolve) => {
206
+ const element = $("dialog");
207
+ element.innerHTML = `
208
+ <form method="dialog" class="dialog-body">
209
+ <h2>${h(title)}</h2>
210
+ <div class="dialog-text">${html ? body : `<p>${h(body)}</p>`}</div>
211
+ <div class="dialog-actions">
212
+ ${cancelText === null ? "" : `<button value="cancel" class="btn btn-secondary">${h(cancelText)}</button>`}
213
+ <button value="confirm" class="btn ${danger ? "btn-danger" : "btn-primary"}">${h(confirmText)}</button>
214
+ </div>
215
+ </form>`;
216
+ element.returnValue = "";
217
+ element.addEventListener("close", () => resolve(element.returnValue === "confirm"), { once: true });
218
+ element.querySelector("form").addEventListener("keydown", (event) => {
219
+ if (event.key === "Enter" && event.target.matches("input")) {
220
+ event.preventDefault();
221
+ element.close("confirm");
222
+ }
223
+ });
224
+ element.showModal();
225
+ const firstInput = element.querySelector(".dialog-text input, .dialog-text select");
226
+ const preferred = element.querySelector(`button[value="${danger && cancelText !== null ? "cancel" : "confirm"}"]`);
227
+ (firstInput ?? preferred).focus();
228
+ });
229
+ }
230
+
231
+ function switchHtml(id, checked, label, hint) {
232
+ return `
233
+ <label class="switch-row" for="${id}">
234
+ <span class="switch-text"><span class="switch-label">${label}</span>${hint ? `<span class="hint">${hint}</span>` : ""}</span>
235
+ <input type="checkbox" role="switch" class="switch" id="${id}" ${checked ? "checked" : ""}>
236
+ </label>`;
237
+ }
238
+
239
+ function segmentedHtml(name, options, value) {
240
+ const buttons = options.map(([key, label]) => `<button type="button" role="radio" aria-checked="${key === value}" data-value="${h(key)}" class="${key === value ? "active" : ""}">${label}</button>`);
241
+ return `<div class="segmented" role="radiogroup" data-name="${name}">${buttons.join("")}</div>`;
242
+ }
243
+
244
+ function bindSegmented(root, name, onPick) {
245
+ const group = root.querySelector(`.segmented[data-name="${name}"]`);
246
+ if (!group) {
247
+ return;
248
+ }
249
+ for (const button of group.querySelectorAll("button")) {
250
+ button.addEventListener("click", () => {
251
+ for (const other of group.querySelectorAll("button")) {
252
+ other.classList.toggle("active", other === button);
253
+ other.setAttribute("aria-checked", String(other === button));
254
+ }
255
+ onPick(button.dataset.value);
256
+ });
257
+ }
258
+ }
259
+
260
+ function emptyState(title, text, action) {
261
+ return `
262
+ <div class="empty">
263
+ <div class="empty-title">${h(title)}</div>
264
+ ${text ? `<div class="empty-text">${text}</div>` : ""}
265
+ ${action ?? ""}
266
+ </div>`;
267
+ }
268
+
269
+ function errorBox(error) {
270
+ const text = String(error?.message ?? error);
271
+ const action = /API key/.test(text) ? `<button class="btn btn-sm js-add-key" style="margin-top:10px">Add API key</button>` : "";
272
+ return `<div class="callout callout-danger" role="alert">${h(text)}${action ? `<div>${action}</div>` : ""}</div>`;
273
+ }
274
+
275
+ function readSetting(key) {
276
+ try {
277
+ return localStorage.getItem(key);
278
+ } catch {
279
+ return null;
280
+ }
281
+ }
282
+
283
+ function writeSetting(key, value) {
284
+ try {
285
+ if (value === null) {
286
+ localStorage.removeItem(key);
287
+ } else {
288
+ localStorage.setItem(key, value);
289
+ }
290
+ } catch {
291
+ }
292
+ }
293
+
294
+ function downloadUrl(url) {
295
+ const link = document.createElement("a");
296
+ link.href = url;
297
+ link.download = "";
298
+ document.body.appendChild(link);
299
+ link.click();
300
+ link.remove();
301
+ }
302
+
303
+ function loadTheme() {
304
+ try {
305
+ const saved = localStorage.getItem(THEME_KEY);
306
+ if (saved) {
307
+ document.documentElement.dataset.theme = saved;
308
+ }
309
+ } catch {
310
+ }
311
+ }
312
+
313
+ function toggleTheme() {
314
+ const dark = document.documentElement.dataset.theme
315
+ ? document.documentElement.dataset.theme === "dark"
316
+ : matchMedia("(prefers-color-scheme: dark)").matches;
317
+ const next = dark ? "light" : "dark";
318
+ document.documentElement.dataset.theme = next;
319
+ try {
320
+ localStorage.setItem(THEME_KEY, next);
321
+ } catch {
322
+ }
323
+ }
324
+
325
+ loadTheme();