feat: add shared date and time formatting

This commit is contained in:
MaxTomahawk
2026-08-11 11:17:32 +02:00
parent 79ed37699e
commit 2c8c800584
8 changed files with 265 additions and 1 deletions
+20
View File
@@ -426,3 +426,23 @@ platform-bridge mod bundled only with that build's launcher, for example).
Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call` Neither hook needs a `Runtime.wantsHook` guard before calling it: `Hooks:call`
already falls straight through to the vanilla function when no mod has already falls straight through to the vanilla function when no mod has
wrapped the name, at negligible cost. wrapped the name, at negligible cost.
## Shared date and time presentation
The global Options menu owns `DATE FORMAT` (`DEVICE`, `DD-MM-YYYY`,
`MM-DD-YYYY`, `YYYY-MM-DD`) and `TIME FORMAT` (`DEVICE`, `24 HOUR`, `12 HOUR`).
These preferences live in `options.lua`, so checkpoint restore never rewinds
them. `DEVICE` uses the process time locale when the platform provides one;
the portable fallback is `DD-MM-YYYY` plus 24-hour time.
Mods format captured timestamps through the read-only public facade:
```lua
local date = mod.datetime:date(game, createdAt)
local time = mod.datetime:time(game, createdAt)
local both = mod.datetime:dateTime(game, createdAt)
```
The live `game` supplies only the current option context. Formatting never
mutates the save, options, or timestamp, and invalid timestamps return
`"----"`.
+90
View File
@@ -0,0 +1,90 @@
-- Shared local date/time presentation for engine UI and mods. Preferences
-- live in options.lua, never checkpoint progress. "device" uses the process
-- time locale when the platform supplies one and otherwise falls back to the
-- deterministic DD-MM-YYYY / 24-hour convention.
local DateTime = {}
local DATE_FORMATS = {
dmy = "%d-%m-%Y",
mdy = "%m-%d-%Y",
ymd = "%Y-%m-%d",
}
local TIME_FORMATS = {
["24h"] = "%H:%M",
["12h"] = "%I:%M %p",
}
local function validTimestamp(value)
return type(value) == "number" and value == value and value >= 0
and value ~= math.huge and value ~= -math.huge
end
local function render(timestamp, pattern)
local ok, value = pcall(os.date, pattern, math.floor(timestamp))
if not ok or type(value) ~= "string" or value == "" then return nil end
return value
end
local function currentLocale()
if not os.setlocale then return nil end
local ok, value = pcall(os.setlocale, nil, "time")
if not ok or type(value) ~= "string" then return nil end
return value
end
local function deviceAvailable(localeName)
return type(localeName) == "string" and localeName ~= ""
and localeName ~= "C" and localeName ~= "POSIX"
end
function DateTime.formatWithLocale(timestamp, datePreference, timePreference, localeName)
if not validTimestamp(timestamp) then return { date = "----", time = "----" } end
local datePattern = DATE_FORMATS[datePreference]
local timePattern = TIME_FORMATS[timePreference]
if not datePattern then
datePattern = deviceAvailable(localeName) and "%x" or DATE_FORMATS.dmy
end
if not timePattern then
if deviceAvailable(localeName) then
local sample = render(timestamp, "%X") or ""
local marker = render(timestamp, "%p") or ""
timePattern = marker ~= "" and sample:find(marker, 1, true)
and TIME_FORMATS["12h"] or TIME_FORMATS["24h"]
else
timePattern = TIME_FORMATS["24h"]
end
end
return {
date = render(timestamp, datePattern) or "----",
time = render(timestamp, timePattern) or "----",
}
end
local function preferences(game)
local options = game and game.save and game.save.options
options = type(options) == "table" and options or {}
return options.dateFormat or "device", options.timeFormat or "device"
end
function DateTime.date(game, timestamp)
local datePreference, timePreference = preferences(game)
return DateTime.formatWithLocale(timestamp, datePreference, timePreference,
currentLocale()).date
end
function DateTime.time(game, timestamp)
local datePreference, timePreference = preferences(game)
return DateTime.formatWithLocale(timestamp, datePreference, timePreference,
currentLocale()).time
end
function DateTime.dateTime(game, timestamp)
local datePreference, timePreference = preferences(game)
local value = DateTime.formatWithLocale(timestamp, datePreference, timePreference,
currentLocale())
if value.date == "----" or value.time == "----" then return "----" end
return value.date .. " " .. value.time
end
return DateTime
+6
View File
@@ -332,6 +332,12 @@ function SaveData.defaultOptions()
-- gets the tick without going looking for the row. Inert wherever the -- gets the tick without going looking for the row. Inert wherever the
-- overlay never appears (desktop) or LOVE has no vibrator. -- overlay never appears (desktop) or LOVE has no vibrator.
haptics = "light", haptics = "light",
-- Shared UI/mod timestamp presentation. DEVICE follows the process time
-- locale where the platform exposes one; otherwise DateTime falls back to
-- DD-MM-YYYY and 24-hour time. Kept in options.lua so checkpoints never
-- rewind presentation preferences.
dateFormat = "device", -- device | dmy | mdy | ymd
timeFormat = "device", -- device | 24h | 12h
} }
end end
+11
View File
@@ -5,6 +5,7 @@ local Data = require("src.core.Data")
local Version = require("src.core.Version") local Version = require("src.core.Version")
local Assets = require("src.render.Assets") local Assets = require("src.render.Assets")
local ModUI = require("src.ui.ModUI") local ModUI = require("src.ui.ModUI")
local DateTime = require("src.core.DateTime")
local AssetTransform = require("src.mods.AssetTransform") local AssetTransform = require("src.mods.AssetTransform")
local Manifest = require("src.mods.Manifest") local Manifest = require("src.mods.Manifest")
local Merge = require("src.mods.Merge") local Merge = require("src.mods.Merge")
@@ -643,6 +644,16 @@ function Loader:_api(mod)
-- the widget toolkit facade (12 4.5) is one shared surface, not -- the widget toolkit facade (12 4.5) is one shared surface, not
-- per-mod state; each widget inside it loads on first touch -- per-mod state; each widget inside it loads on first touch
ui = ModUI, ui = ModUI,
-- Read-only shared timestamp presentation using current options.lua
-- preferences. The live game supplies only the current option context;
-- checkpoint/save data never changes as a side effect.
datetime = {
date = function(_, game, timestamp) return DateTime.date(game, timestamp) end,
time = function(_, game, timestamp) return DateTime.time(game, timestamp) end,
dateTime = function(_, game, timestamp)
return DateTime.dateTime(game, timestamp)
end,
},
-- namespaced per mod; M11 backs these with save.modData / -- namespaced per mod; M11 backs these with save.modData /
-- options.modOptions, the shape mods compile against is already final -- options.modOptions, the shape mods compile against is already final
save = { save = {
+42
View File
@@ -49,6 +49,30 @@ local Rulesets = {
modern_clean = require("src.battle.rulesets.modern_clean"), modern_clean = require("src.battle.rulesets.modern_clean"),
} }
local FILTERS = { "OFF", "1X", "2X", "3X" } local FILTERS = { "OFF", "1X", "2X", "3X" }
local DATE_FORMATS = {
{ "device", "DEVICE" }, { "dmy", "DD-MM-YYYY" },
{ "mdy", "MM-DD-YYYY" }, { "ymd", "YYYY-MM-DD" },
}
local TIME_FORMATS = {
{ "device", "DEVICE" }, { "24h", "24 HOUR" }, { "12h", "12 HOUR" },
}
local function preferenceIndex(rows, value)
for index, row in ipairs(rows) do
if row[1] == value then return index end
end
return 1
end
local function preferenceStep(rows, value, direction)
local index = preferenceIndex(rows, value)
direction = direction and direction < 0 and -1 or 1
return rows[((index - 1 + direction) % #rows) + 1][1]
end
local function preferenceLabel(rows, value)
return rows[preferenceIndex(rows, value)][2]
end
local function speedIndex(game) local function speedIndex(game)
-- default matches InitOptions' TEXT_DELAY_MEDIUM in wOptions -- default matches InitOptions' TEXT_DELAY_MEDIUM in wOptions
@@ -439,6 +463,24 @@ local function buildRows(game)
activate = function(g) activate = function(g)
require("src.ui.Screens").push(g, "BindingsMenu") require("src.ui.Screens").push(g, "BindingsMenu")
end }, end },
{ id = "dateFormat", label = Strings("DATE FORMAT"),
value = function(g)
return Strings(preferenceLabel(DATE_FORMATS, g.save.options.dateFormat))
end,
step = function(g, dir)
g.save.options.dateFormat = preferenceStep(
DATE_FORMATS, g.save.options.dateFormat, dir)
return true
end },
{ id = "timeFormat", label = Strings("TIME FORMAT"),
value = function(g)
return Strings(preferenceLabel(TIME_FORMATS, g.save.options.timeFormat))
end,
step = function(g, dir)
g.save.options.timeFormat = preferenceStep(
TIME_FORMATS, g.save.options.timeFormat, dir)
return true
end },
-- permanent on-screen pad toggle (#327); layout editing stays in the -- permanent on-screen pad toggle (#327); layout editing stays in the
-- launcher. Hidden where the overlay never appears (desktop without -- launcher. Hidden where the overlay never appears (desktop without
-- POKEPORT_TOUCH), so the row costs a non-mobile install nothing. -- POKEPORT_TOUCH), so the row costs a non-mobile install nothing.
+41
View File
@@ -0,0 +1,41 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local DateTime = require("src.core.DateTime")
local SaveData = require("src.core.SaveData")
local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 })
local defaults = SaveData.defaultOptions()
T.eq(defaults.dateFormat, "device", "date format defaults to device locale")
T.eq(defaults.timeFormat, "device", "time format defaults to device locale")
local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } }
T.eq(DateTime.date(game, stamp), os.date("%d-%m-%Y", stamp),
"explicit DMY uses day-month-year")
T.eq(DateTime.time(game, stamp), os.date("%H:%M", stamp),
"explicit 24-hour time omits seconds")
T.eq(DateTime.dateTime(game, stamp),
os.date("%d-%m-%Y %H:%M", stamp),
"combined formatter composes exact date and time preferences")
game.save.options.dateFormat = "mdy"
T.eq(DateTime.date(game, stamp), os.date("%m-%d-%Y", stamp),
"explicit MDY is available")
game.save.options.dateFormat = "ymd"
T.eq(DateTime.date(game, stamp), os.date("%Y-%m-%d", stamp),
"explicit YMD is available")
game.save.options.timeFormat = "12h"
T.eq(DateTime.time(game, stamp), os.date("%I:%M %p", stamp),
"explicit 12-hour time is available")
local fallback = DateTime.formatWithLocale(stamp, "device", "device", "C")
T.eq(fallback.date, os.date("%d-%m-%Y", stamp),
"missing device locale falls back to requested DMY")
T.eq(fallback.time, os.date("%H:%M", stamp),
"missing device locale falls back to requested 24-hour time")
local invalid = DateTime.date({}, -1)
T.eq(invalid, "----", "invalid timestamps fail closed")
T.finish("date_time")
+18 -1
View File
@@ -288,7 +288,7 @@ local WANT_IDS = { "textSpeed", "animations", "battleStyle", "battleLayout",
"tilt", "gbcfx", "zoom", "voidFill", "videoMode", "tilt", "gbcfx", "zoom", "voidFill", "videoMode",
"faithfulRes", "fpsCap", "faithfulRes", "fpsCap",
"speedOverworld", "speedBattle", "speedMenu", "speedOverworld", "speedBattle", "speedMenu",
"mods", "controls" } "mods", "controls", "dateFormat", "timeFormat" }
check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)") check(#om.rows == #WANT_IDS, "vanilla options row count (plus MODS/CONTROLS)")
for i, id in ipairs(WANT_IDS) do for i, id in ipairs(WANT_IDS) do
check(om.rows[i].id == id, "options row order: " .. id) check(om.rows[i].id == id, "options row order: " .. id)
@@ -408,6 +408,23 @@ check(bm.items[1].label == "UP" and bm.items[1].right == "UP/D-UP"
"with no rebind the rows mirror the fixed map, key and pad both (#589)") "with no rebind the rows mirror the fixed map, key and pad both (#589)")
check(cbGame.save.options.bindings == nil, check(cbGame.save.options.bindings == nil,
"opening the screen alone writes nothing") "opening the screen alone writes nothing")
-- shared date/time presentation stays in options.lua and is available to
-- engine UI and mods without becoming checkpoint progress
om.game.save.options.dateFormat = "device"
om.game.save.options.timeFormat = "device"
check(om.rows[26].value(om.game) == "DEVICE",
"DATE FORMAT defaults to device locale")
om.rows[26].step(om.game, 1)
check(om.game.save.options.dateFormat == "dmy"
and om.rows[26].value(om.game) == "DD-MM-YYYY",
"DATE FORMAT exposes deterministic DMY override")
check(om.rows[27].value(om.game) == "DEVICE",
"TIME FORMAT defaults to device locale")
om.rows[27].step(om.game, 1)
check(om.game.save.options.timeFormat == "24h"
and om.rows[27].value(om.game) == "24 HOUR",
"TIME FORMAT exposes deterministic 24-hour override")
check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil, check(bm.onKeyPressed == nil and bm.onGamepadPressed == nil,
"no raw-input claim until a capture is armed") "no raw-input claim until a capture is armed")
press(bm, "a") press(bm, "a")
+37
View File
@@ -0,0 +1,37 @@
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.modkit")
local FIXTURE = {
["mods/date_probe/manifest.json"] = [[{
"id": "date_probe",
"name": "Date Probe",
"version": "1.0.0",
"entry": "main.lua",
"api": 2
}]],
["mods/date_probe/main.lua"] = [[
local mod = ...
mod.exports.datetime = mod.datetime
]],
}
local run = T.sdk.loadMods({ "mods/date_probe" }, { fs = T.sdk.memfs(FIXTURE) })
T.eq(#run.errors, 0, "fixture mod loads cleanly")
local datetime = run.loader.exports.date_probe.datetime
T.eq(type(datetime), "table", "mod object exposes datetime formatting")
T.eq(type(datetime.date), "function", "public formatter exposes date")
T.eq(type(datetime.time), "function", "public formatter exposes time")
T.eq(type(datetime.dateTime), "function", "public formatter exposes date-time")
local stamp = os.time({ year = 2026, month = 8, day = 11, hour = 17, min = 5, sec = 0 })
local game = { save = { options = { dateFormat = "dmy", timeFormat = "24h" } } }
T.eq(datetime:date(game, stamp), os.date("%d-%m-%Y", stamp),
"mod date follows current global engine preference")
T.eq(datetime:time(game, stamp), os.date("%H:%M", stamp),
"mod time follows current global engine preference")
T.eq(datetime:dateTime(game, stamp), os.date("%d-%m-%Y %H:%M", stamp),
"mod date-time composes the same preference")
run.release()
T.finish("date_time")