commit 99e94ddb600ca1650830249c4dfceac7b495c1bc
parent 8da7ff715c718e0e1757bf59053b38073c4ac9e1
Author: Jacob R. Edwards <jacob@jacobedwards.org>
Date: Tue, 5 Mar 2024 17:19:35 -0800
Implement and any number of times (now including break and end times)
Times are now stored in the database in a JSON array (using the
json_* functions). Support for "start break", "end break", and "end
time" was also added.
All the data is displayed in an HTML table. Some CSS was also added
to make the table easier to read.
Diffstat:
4 files changed, 365 insertions(+), 71 deletions(-)
diff --git a/Makefile b/Makefile
@@ -22,6 +22,8 @@ clean:
install: ${name}
mkdir -p ${prefix}/scripts
cp scripts/* ${prefix}/scripts
+ mkdir -p ${prefix}/css
+ cp css/* ${prefix}/css
# Otherwise 'Text file is busy'
pkill kfcgi || true
@@ -31,6 +33,7 @@ install: ${name}
uninstall:
rm -f ${prefix}/${name}
rm -rf ${prefix}/scripts
+ rm -rf ${prefix}/css
.PHONY: clean install uninstall
.SUFFIX: .c .o
diff --git a/css/main.css b/css/main.css
@@ -0,0 +1,19 @@
+table, tr, th {
+ border-collapse: collapse;
+ border-spacing: 0;
+ border: 1px solid black;
+ font-weight: normal;
+}
+
+th {
+ text-align: left;
+ padding: 0.2rem;
+}
+
+th[scope=col] {
+ background-color: lightblue;
+}
+
+tr:nth-child(even) {
+ background-color: #F3F3F3;
+}
diff --git a/scripts/main.js b/scripts/main.js
@@ -1,17 +1,31 @@
-start = document.querySelector("#start")
-let starttime = Math.trunc(Date.parse(start.dateTime) / 1000)
+function getduration(s) {
+ var hours = s.match(/(\d+)\s*h/);
+ var minutes = s.match(/(\d+)\s*m/);
+ var seconds = s.match(/(\d+)\s*s/);
+ return (parseInt(hours[1]) * 3600) + (parseInt(minutes[1]) * 60) +
+ parseInt(seconds[1])
+}
+
+function gettime() {
+ return Math.trunc(Date.now() / 1000)
+}
+
+let start = document.getElementById("counter")
+console.log(getduration(start.dateTime))
+let starttime = gettime() - getduration(start.dateTime)
function sn(n) {
return String(n).padStart(2, "0")
}
function updatetime() {
- let elapsed = Math.trunc(Date.now() / 1000) - starttime
+ let elapsed = gettime() - starttime
let mn = Math.trunc(elapsed / 60)
let sc = elapsed % 60
let hr = Math.trunc(mn / 60)
mn = mn % 60
start.textContent = sn(hr) + ':' + sn(mn) + ':' + sn(sc)
+ /* if (start.checkVisibility()) */
setTimeout(updatetime, 1000)
}
diff --git a/timekeeper.c b/timekeeper.c
@@ -22,6 +22,8 @@
#define Promises "stdio rpath wpath cpath proc recvfd unix sendfd"
#define Len(X) (sizeof(X) / sizeof(X[0]))
+#define Max(A,B) ((A) > (B) ? (A) : (B))
+#define Min(A,B) ((A) < (B) ? (A) : (B))
#define NameMin 3
#define NameMax 64
@@ -34,8 +36,7 @@ enum Field {
KeyHash, /* Authentication hash */
KeyCreate, /* Whether to create a new user in login page */
KeyDelete, /* Delete account in account page */
- KeyStart,
- KeyStop,
+ KeyTime, /* one of start, startbreak, endbreak, end */
KeyMax
};
@@ -59,11 +60,19 @@ enum StmtID {
StmtGetUserByHash,
StmtGetUserByName,
StmtDeleteUser,
- StmtStartTime,
- StmtGetStartTime,
+ StmtNewTimeRow,
+ StmtSetTime,
+ StmtGetTimes,
StmtMax
};
+enum TimeField {
+ StartTime,
+ BreakStartTime,
+ BreakEndTime,
+ EndTime
+};
+
struct pagedata {
struct kreq req;
struct khtmlreq html;
@@ -86,6 +95,13 @@ static char *pages[] = {
[Page404] = "404"
};
+char *timefields[] = {
+ [StartTime] = "start",
+ [BreakStartTime] = "startbreak",
+ [BreakEndTime] = "endbreak",
+ [EndTime] = "end"
+};
+
int
kvalid_name(struct kpair *p)
{
@@ -259,7 +275,13 @@ starthtmldoc(struct pagedata *pd, enum khttp code)
if ((status = khttp_head(&pd->req, kresps[KRESP_STATUS], "%s", khttps[code])) != KCGI_OK ||
(status = khttp_head(&pd->req, kresps[KRESP_CONTENT_TYPE], "%s", kmimetypes[KMIME_TEXT_HTML])) != KCGI_OK ||
(status = khttp_body(&pd->req)) != KCGI_OK ||
- (status = khtml_open(&pd->html, &pd->req, KHTML_PRETTY)) != KCGI_OK)
+ (status = khtml_open(&pd->html, &pd->req, KHTML_PRETTY)) != KCGI_OK ||
+ (status = khtml_elem(&pd->html, KELEM_HTML)) != KCGI_OK ||
+ (status = khtml_elem(&pd->html, KELEM_HEAD)) != KCGI_OK ||
+ (status = khtml_attr(&pd->html, KELEM_LINK,
+ KATTR_HREF, "css/main.css", KATTR_REL, "stylesheet", KATTR__MAX)) != KCGI_OK ||
+ (status = khtml_closeelem(&pd->html, 1)) != KCGI_OK ||
+ (status = khtml_elem(&pd->html, KELEM_BODY) != KCGI_OK))
return status;
kcgi_writer_disable(&pd->req);
@@ -527,29 +549,47 @@ pageaccount(struct pagedata *pd)
return khtml_close(&pd->html);
}
-time_t
-setstart(struct pagedata *pd, char *hash, time_t time)
+enum sqlbox_code
+newrow(struct pagedata *pd, char *hash)
+{
+ struct sqlbox_parm ps[] = {
+ { .sparm = hash, .type = SQLBOX_PARM_STRING }
+ };
+
+ return sqlbox_exec(pd->db, pd->dbid, StmtNewTimeRow,
+ Len(ps), ps, 0) != SQLBOX_CODE_OK;
+}
+
+enum sqlbox_code
+settime(struct pagedata *pd, char *hash, enum TimeField f, time_t time)
{
struct sqlbox_parm ps[] = {
+ { .iparm = f, .type = SQLBOX_PARM_INT },
{ .iparm = time, .type = SQLBOX_PARM_INT },
{ .sparm = hash, .type = SQLBOX_PARM_STRING },
+ { .iparm = f, .type = SQLBOX_PARM_INT }
};
- return sqlbox_exec(pd->db, pd->dbid, StmtStartTime,
+ if (!time)
+ return SQLBOX_CODE_ERROR;
+ return sqlbox_exec(pd->db, pd->dbid, StmtSetTime,
Len(ps), ps, 0) != SQLBOX_CODE_OK;
}
-time_t
-getstart(struct pagedata *pd, char *hash)
+time_t *
+gettimes(struct pagedata *pd, char *hash, size_t *relem)
{
size_t stmtid;
struct sqlbox_parm p = {
.sparm = hash, .type = SQLBOX_PARM_STRING
};
struct sqlbox_parmset *r;
- time_t time;
-
- stmtid = sqlbox_prepare_bind(pd->db, pd->dbid, StmtGetStartTime, 1, &p, 0);
+ time_t *times;
+ size_t i;
+
+ assert(relem);
+
+ stmtid = sqlbox_prepare_bind(pd->db, pd->dbid, StmtGetTimes, 1, &p, 0);
if (stmtid == 0)
err(1, "prepare bind");
@@ -557,87 +597,296 @@ getstart(struct pagedata *pd, char *hash)
if (!r || r->code != SQLBOX_CODE_OK)
err(1, "step");
- if (r->psz > 1)
- err(1, "too many rows");
-
- if (r->psz == 1 && (r->ps[0].type != SQLBOX_PARM_INT &&
- r->ps[0].type != SQLBOX_PARM_NULL))
- err(1, "invalid type");
+ assert(r->psz == 1 && r->ps[0].type == SQLBOX_PARM_INT);
+ assert(r->ps[0].iparm >= 0);
+
+ *relem = r->ps[0].iparm;
+ kutil_info(NULL,NULL,"Number of times: %zu", *relem);
+ times = calloc(*relem, sizeof(*times));
+ if (!times)
+ err(1, "calloc");
+
+ for (i = 0; i < *relem && (r = sqlbox_step(pd->db, stmtid)) &&
+ !(!r->psz && r->code == SQLBOX_CODE_OK); ++i) {
+ assert(r->psz == 1);
+ assert(r->ps[0].type == SQLBOX_PARM_INT ||
+ r->ps[0].type == SQLBOX_PARM_NULL);
+ times[i] = (r->ps[0].type == SQLBOX_PARM_NULL) ? 0 : r->ps[0].iparm;
+ kutil_info(NULL, NULL, "(%zu)time[%zu] = %lld", *relem, i, times[i]);
+ }
+ assert(r);
- time = r->psz == 1 ? r->ps[0].iparm : 0;
- kutil_info(NULL,NULL,"gettime %lld %zu", time, r->psz);
+ while (*relem > 0 && !times[*relem - 1])
+ --*relem;
- if (!sqlbox_finalise(pd->db, stmtid))
+ if (!sqlbox_finalise(pd->db, stmtid)) {
+ free(times);
err(1, "finalise");
- return time;
+ }
+
+ return times;
}
+#define DateTimeSize 25
+
+static char *datetime_fmt = "%FT%T+0000";
+
enum kcgi_err
-pagemain(struct pagedata *pd)
+printhours(struct pagedata *pd, time_t *times, int len)
{
enum kcgi_err status;
- struct user *user;
- time_t start;
time_t elapsed;
- char datetime[25];
- int hr, mn, sc;
+ time_t hr, mn, sc;
struct tm *tm;
+ char datetime[13]; /* allows up to four digits for hours */
+ time_t to, from, breaktime;
+
+ assert(len > 0);
+ from = times[StartTime];
+ assert(from);
+ to = ((len - 1) >= EndTime) ? times[EndTime] : 0;
+ breaktime = 0;
+
+ if ((len - 1) == BreakStartTime && times[BreakStartTime])
+ to = times[BreakStartTime];
+ else if ((len - 1) >= BreakEndTime &&
+ times[BreakStartTime] && times[BreakEndTime])
+ breaktime = times[BreakEndTime] - times[BreakStartTime];
+
+ elapsed = ((to ? to : time(NULL)) - from) - breaktime;
+ kutil_info(&pd->req, NULL, "printhours: from %lld, to %lld, elapsed %lld, len %d",
+ from, to, elapsed, len);
+ assert(elapsed >= 0);
+
+ mn = elapsed / 60;
+ sc = elapsed % 60;
+ hr = mn / 60;
+ mn = mn % 60;
+
+ tm = gmtime(&from);
+ if (!tm)
+ err(1, "gmtime");
+
+ if (snprintf(datetime, sizeof(datetime), "%lldh%lldm%llds",
+ hr, mn, sc) >= (int)sizeof(datetime)) {
+ err(1, "snprintf (%lldh %lldm %llds)", hr, mn, sc);
+ }
- user = sitegetlogin(pd);
- if (!user)
- return errorpage(pd, KHTTP_401);
-
- if (pd->req.fieldmap[KeyStart] && setstart(pd, user->hash, time(NULL)))
- return KCGI_SYSTEM;
+ if (!to)
+ status = khtml_attr(&pd->html, KELEM_TIME,
+ KATTR_ID, "counter", KATTR_DATETIME, datetime, KATTR__MAX);
+ else
+ status = khtml_attr(&pd->html, KELEM_TIME,
+ KATTR_DATETIME, datetime, KATTR__MAX);
+ if (status)
+ return status;
- status = starthtmldoc(pd, KHTTP_200);
- if (status != KCGI_OK)
+ if ((status = khtml_printf(&pd->html, "%.2lld:%.2lld:%.2lld", hr, mn, sc)) != KCGI_OK)
return status;
- start = getstart(pd, user->hash);
+ if (!to) {
+ if ((status = khtml_elem(&pd->html, KELEM_NOSCRIPT)) != KCGI_OK ||
+ (status = khtml_attr(&pd->html, KELEM_A,
+ KATTR_HREF, pages[PageMain], KATTR__MAX)) != KCGI_OK ||
+ (status = khtml_putc(&pd->html, ' ')) != KCGI_OK ||
+ (status = htmlwithin(pd, KELEM_BUTTON, "Reload")) != KCGI_OK ||
+ (status = khtml_closeelem(&pd->html, 2)) != KCGI_OK ||
+ (status = khtml_attr(&pd->html, KELEM_SCRIPT,
+ KATTR_SRC, "scripts/main.js", KATTR__MAX)) != KCGI_OK)
+ return status;
+ }
+
+ return khtml_closeelem(&pd->html, 1);
+}
- if ((status = htmlwithin(pd, KELEM_H1, "Timekeeper")) != KCGI_OK ||
- (status = khtml_elem(&pd->html, KELEM_FORM)) != KCGI_OK ||
+enum kcgi_err
+printtimebutton(struct pagedata *pd, enum TimeField tf)
+{
+ enum kcgi_err status;
+ static char *buttons[] = {
+ [StartTime] = "Start",
+ [BreakStartTime] = "Start Break",
+ [BreakEndTime] = "End Break",
+ [EndTime] = "Stop Time"
+ };
+
+ if ((status = khtml_elem(&pd->html, KELEM_FORM)) != KCGI_OK ||
(status = khtml_attr(&pd->html, KELEM_INPUT,
KATTR_TYPE, "hidden",
- KATTR_NAME, pd->keys[start ? KeyStop : KeyStart].name,
- KATTR_VALUE, "yes",
+ KATTR_NAME, pd->keys[KeyTime].name,
+ KATTR_VALUE, timefields[tf],
KATTR__MAX)) != KCGI_OK ||
(status = khtml_attr(&pd->html, KELEM_INPUT,
KATTR_TYPE, "submit",
- KATTR_VALUE, start ? "Stop Time" : "Start Time", KATTR__MAX)) != KCGI_OK ||
+ KATTR_VALUE, buttons[tf], KATTR__MAX)) != KCGI_OK ||
(status = khtml_closeelem(&pd->html, 1)) != KCGI_OK)
return status;
+ return KCGI_OK;
+}
+
+/* prints one row in times table */
+enum kcgi_err
+printtime(struct pagedata *pd, time_t *times, unsigned int len)
+{
+ unsigned int i;
+ struct tm *tm;
+ char datetime[DateTimeSize];
+ char date[11]; /* %F */
+ char time[6]; /* %R */
+ enum kcgi_err status;
- if (start) {
- elapsed = time(NULL) - start;
- mn = elapsed / 60;
- sc = elapsed % 60;
- hr = mn / 60;
- mn = mn % 60;
- tm = gmtime(&start);
+ if (len > 4)
+ return KCGI_SYSTEM;
+
+ if (len > 0) {
+ tm = gmtime(×[0]);
if (!tm)
- err(1, "gmtime");
- if (strftime(datetime, sizeof(datetime), "%FT%T+0000", tm) >= (int)sizeof(datetime))
- err(1, "strftime");
- if ((status = khtml_attr(&pd->html, KELEM_TIME,
- KATTR_ID, "start", KATTR_DATETIME, datetime,
- KATTR__MAX)) != KCGI_OK ||
- (status = khtml_printf(&pd->html, "%.2d:%.2d:%.2d", hr, mn, sc)) != KCGI_OK ||
+ return KCGI_SYSTEM;
+ if (strftime(date, sizeof(date), "%F", tm) >= sizeof(date))
+ return KCGI_SYSTEM;
+ } else {
+ date[0] = 0;
+ }
+
+ if ((status = khtml_attr(&pd->html, KELEM_TH,
+ KATTR_SCOPE, "row", KATTR__MAX)) != KCGI_OK ||
+ (status = khtml_puts(&pd->html, date)) != KCGI_OK ||
+ (status = khtml_closeelem(&pd->html, 1)) != KCGI_OK)
+ return status;
+
+ for (i = 0; i < 4; ++i) {
+ if (i >= len || !times[i]) {
+ if ((status = khtml_elem(&pd->html, KELEM_TH)) != KCGI_OK)
+ return status;
+
+ if (len == StartTime && i == StartTime)
+ status = printtimebutton(pd, StartTime);
+ else if (len == BreakStartTime && (i == BreakStartTime || i == EndTime))
+ status = printtimebutton(pd, i);
+ else if (len > BreakStartTime && ((times[BreakStartTime] && i == BreakEndTime) || i == EndTime))
+ status = printtimebutton(pd, i);
+ else
+ status = khtml_putc(&pd->html, '-');
+
+ if (status != KCGI_OK || (status = khtml_closeelem(&pd->html, 1)) != KCGI_OK)
+ return status;
+ continue;
+ }
+
+ if (i) {
+ tm = gmtime(×[i]);
+ if (!tm)
+ return KCGI_SYSTEM;
+ }
+
+ if (strftime(datetime, sizeof(datetime), datetime_fmt, tm) >= sizeof(datetime) ||
+ strftime(time, sizeof(time), "%R", tm) >= sizeof(time))
+ return KCGI_SYSTEM;
+
+ if ((status = khtml_elem(&pd->html, KELEM_TH)) != KCGI_OK ||
+ (status = khtml_attr(&pd->html, KELEM_TIME,
+ KATTR_DATETIME, datetime, KATTR__MAX)) != KCGI_OK ||
+ (status = khtml_puts(&pd->html, time)) != KCGI_OK ||
+ (status = khtml_closeelem(&pd->html, 2)) != KCGI_OK)
+ return status;
+ }
+
+ if ((status = khtml_elem(&pd->html, KELEM_TH)) != KCGI_OK ||
+ (len && (status = printhours(pd, times, len)) != KCGI_OK) ||
+ (status = khtml_closeelem(&pd->html, 1)) != KCGI_OK)
+ return status;
+ return KCGI_OK;
+}
+
+enum kcgi_err
+printtimes(struct pagedata *pd, time_t *times, size_t len)
+{
+ unsigned int i;
+ enum kcgi_err status;
+ char *headers[] = {
+ "Date", "Start time", "Break start", "Break end", "End time", "Hours"
+ };
+
+ if ((status = khtml_elem(&pd->html, KELEM_TABLE)) != KCGI_OK ||
+ (status = htmlwithin(pd, KELEM_CAPTION, "Timesheets")) != KCGI_OK)
+ return status;
+
+ if ((status = khtml_elem(&pd->html, KELEM_THEAD)) != KCGI_OK ||
+ (status = khtml_elem(&pd->html, KELEM_TR)) != KCGI_OK)
+ return status;
+
+ for (i = 0; i < Len(headers); ++i) {
+ if ((status = khtml_attr(&pd->html, KELEM_TH,
+ KATTR_SCOPE, "col", KATTR__MAX)) != KCGI_OK ||
+ (status = khtml_puts(&pd->html, headers[i])) != KCGI_OK ||
(status = khtml_closeelem(&pd->html, 1)) != KCGI_OK)
return status;
+ }
- if ((status = khtml_elem(&pd->html, KELEM_NOSCRIPT)) != KCGI_OK ||
- (status = khtml_attr(&pd->html, KELEM_A,
- KATTR_HREF, pages[PageMain], KATTR__MAX)) != KCGI_OK ||
- (status = htmlwithin(pd, KELEM_BUTTON, "Reload")) != KCGI_OK ||
- (status = khtml_closeelem(&pd->html, 2)) != KCGI_OK ||
- (status = khtml_attr(&pd->html, KELEM_SCRIPT,
- KATTR_SRC, "scripts/main.js", KATTR__MAX)) != KCGI_OK ||
+ if ((status = khtml_closeelem(&pd->html, 2)) != KCGI_OK ||
+ (status = khtml_elem(&pd->html, KELEM_TBODY)) != KCGI_OK)
+ return status;
+
+ for (i = 0; i <= len; i += 4) {
+ if ((status = khtml_elem(&pd->html, KELEM_TR)) != KCGI_OK ||
+ (status = printtime(pd, ×[i], Min(len - i, 4))) != KCGI_OK ||
(status = khtml_closeelem(&pd->html, 1)) != KCGI_OK)
return status;
}
+ return khtml_closeelem(&pd->html, 2);
+}
+
+int
+gettf(char *tfs)
+{
+ int i;
+
+ for (i = 0; i < (int)Len(timefields); ++i) {
+ if (strcmp(timefields[i], tfs) == 0)
+ return i;
+ }
+ return -1;
+}
+
+enum kcgi_err
+pagemain(struct pagedata *pd)
+{
+ enum kcgi_err status;
+ struct user *user;
+ enum TimeField tf;
+ time_t *times;
+ size_t ntimes;
+
+ user = sitegetlogin(pd);
+ if (!user)
+ return errorpage(pd, KHTTP_401);
+
+ if (pd->req.fieldmap[KeyTime]) {
+ tf = gettf(pd->req.fieldmap[KeyTime]->parsed.s);
+ if (tf < 0)
+ err(1, "Invalid time field");
+ if (tf == StartTime && newrow(pd, user->hash) != SQLBOX_CODE_OK)
+ err(1, "New time");
+ if (settime(pd, user->hash, tf, time(NULL)))
+ return KCGI_SYSTEM;
+ }
+
+ status = starthtmldoc(pd, KHTTP_200);
+ if (status != KCGI_OK)
+ return status;
+
+ times = gettimes(pd, user->hash, &ntimes);
+ if (!times)
+ err(1, "gettimes");
+
+ if ((status = htmlwithin(pd, KELEM_H1, "Timekeeper")) != KCGI_OK ||
+ (status = printtimes(pd, times, ntimes)) != KCGI_OK) {
+ free(times);
+ return status;
+ }
+
+ free(times);
freeuser(user);
return khtml_close(&pd->html);
}
@@ -653,8 +902,7 @@ main(void)
[KeyHash] = { kvalid_stringne, "hash" },
[KeyCreate] = { kvalid_stringne, "create" },
[KeyDelete] = { kvalid_stringne, "delete" },
- [KeyStart] = { kvalid_stringne, "start" },
- [KeyStop] = { kvalid_stringne, "stop" }
+ [KeyTime] = { kvalid_stringne, "time" }
};
struct pagedata pd = {
.keys = keys
@@ -666,7 +914,7 @@ main(void)
struct sqlbox_pstmt pstmts[] = {
[StmtInit0] = { .stmt = "PRAGMA foreign_keys = ON" },
[StmtInit1] = { .stmt = "CREATE TABLE IF NOT EXISTS auth (hash TEXT PRIMARY KEY, name TEXT UNIQUE)" },
- [StmtInit2] = { .stmt = "CREATE TABLE IF NOT EXISTS userdata (id TEXT PRIMARY KEY REFERENCES auth (hash), start INTEGER)" },
+ [StmtInit2] = { .stmt = "CREATE TABLE IF NOT EXISTS userdata (id TEXT PRIMARY KEY REFERENCES auth (hash), times BLOB DEFAULT '[]')" },
[StmtInit3] = { .stmt = "CREATE TRIGGER IF NOT EXISTS userdatahash AFTER INSERT ON auth FOR EACH ROW BEGIN\n"
"INSERT INTO userdata (id) VALUES (NEW.hash); END" },
[StmtInitLast] = { .stmt = "CREATE TRIGGER IF NOT EXISTS userdelete AFTER DELETE ON auth FOR EACH ROW BEGIN\n"
@@ -675,8 +923,18 @@ main(void)
[StmtGetUserByName] = { .stmt = "SELECT * FROM auth WHERE name IS ?" },
[StmtGetUserByHash] = { .stmt = "SELECT * FROM auth WHERE hash IS ?" },
[StmtDeleteUser] = { .stmt = "DELETE FROM auth WHERE hash IS ?" },
- [StmtStartTime] = { .stmt = "UPDATE userdata SET start = ? WHERE hash IS ?" },
- [StmtGetStartTime] = { .stmt = "SELECT start FROM userdata WHERE hash IS ?" }
+ [StmtNewTimeRow] = { .stmt = "UPDATE userdata SET times =\n"
+ "json_insert(times, '$[#]', json_array(null, null, null, null)) WHERE id IS ? AND\n"
+ "(json_array_length(times) = 0 OR json_extract(times, '$[#-1][3]') IS NOT null)" },
+ /* Currently, the user can set the end time with a
+ * break open and the break won't be ended. This is
+ * intentional, but also it should set the break end
+ * time to the end time in that case.
+ */
+ [StmtSetTime] = { .stmt = "UPDATE userdata SET times =\n"
+ "json_replace(times, '$[#-1][' || ? || ']', ?) WHERE id IS ? AND json_extract(times, '$[#-1][' || ? || ']') IS null" },
+ [StmtGetTimes] = { .stmt = "SELECT json_array_length(times) * 4 FROM userdata UNION ALL\n"
+ "SELECT value FROM userdata, json_tree(times) WHERE userdata.id IS ? AND type IS NOT 'array'" }
};
enum kcgi_err (*pagefunctions[])(struct pagedata *) = {
[PageIndex] = pageindex,