MEDIAN·MODE·STDEV(.S/.P)·VAR·RANK(.EQ)·LARGE·SMALL·PERCENTILE·QUARTILE· COUNTBLANK을 새 spreadsheet_functions_stat.ts에 둠. NOW·WEEKDAY·EDATE· EOMONTH·DATEDIF·NETWORKDAYS·HOUR·MINUTE·TIME을 misc.ts에 더함(EDATE는 월말 넘침을 다음 달로 안 넘기고 그 달 말일로 눌러 담음 — 엑셀과 같게). numfmt에 날짜 형식(yyyy-mm-dd·m/d·요일 aaa/aaaa)을 더함. sub1 func_help용 이름 목록은 functions.ts의 FUNC_HELP_BATCH3에 둠(그 파일은 안 고침). 경계값 시험 180건 전부 통과. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RrQ65VtGZbae2VKgYVMhmc
657 lines
19 KiB
TypeScript
657 lines
19 KiB
TypeScript
/* =============================================================================
|
|
* spreadsheet_functions_misc.ts (주인 B)
|
|
* 논리 · 글 · 정보 · 날짜 함수. 찾기 · 조건부 세기/합/평균/최대/최소는
|
|
* `spreadsheet_functions_lookup.ts` 로 옮김(700 줄 규칙 · 2026-09-27).
|
|
* 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
ZERO,
|
|
cmp,
|
|
frac,
|
|
mul,
|
|
parseDecimal,
|
|
roundAt,
|
|
toInteger,
|
|
type Frac,
|
|
} from "@ui/sheet/ui_template_sheet_frac";
|
|
import type { ErrorValue, LazyArg, SheetFunction } from "./spreadsheet_types";
|
|
import { formatValue } from "./spreadsheet_numfmt";
|
|
import {
|
|
bool,
|
|
fracToDouble,
|
|
fromDouble,
|
|
isErr,
|
|
isFrac,
|
|
isRange,
|
|
mkErr,
|
|
num,
|
|
str,
|
|
toBool,
|
|
toScalar,
|
|
toText,
|
|
} from "./spreadsheet_functions_math";
|
|
|
|
const ONE = frac(1n);
|
|
|
|
/** SEARCH — 통짜 맞춤이 아니라 안 어디서든 찾음(자리 없이 그대로 씀). */
|
|
function wildcardToSearchRegex(text: string): RegExp {
|
|
const escaped = text
|
|
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
.replace(/\*/g, ".*")
|
|
.replace(/\?/g, ".");
|
|
return new RegExp(escaped, "i");
|
|
}
|
|
|
|
// ── 숫자 → 고정 소수 글(FIXED) ───────────────────────────────────────────────
|
|
|
|
function groupThousands(digits: string): string {
|
|
return digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
}
|
|
|
|
function formatFixed(n: Frac, decimals: number, commas: boolean): string {
|
|
const d = Math.max(0, decimals);
|
|
const rounded = roundAt(n, d, "round");
|
|
const negative = cmp(rounded, ZERO) < 0;
|
|
const magnitude = negative ? frac(-rounded.n, rounded.d) : rounded;
|
|
const scaled = toInteger(mul(magnitude, frac(10n ** BigInt(d))), "round");
|
|
const digits = scaled.toString().padStart(d + 1, "0");
|
|
let whole = d > 0 ? digits.slice(0, -d) : digits;
|
|
const fraction = d > 0 ? digits.slice(-d) : "";
|
|
if (commas) whole = groupThousands(whole);
|
|
const body = fraction ? `${whole}.${fraction}` : whole;
|
|
return negative ? `-${body}` : body;
|
|
}
|
|
|
|
function collectBools(args: LazyArg[]): boolean[] | ErrorValue {
|
|
const out: boolean[] = [];
|
|
for (const a of args) {
|
|
const v = a();
|
|
if (isRange(v)) {
|
|
for (let r = 0; r < v.rows; r++) {
|
|
for (let c = 0; c < v.cols; c++) {
|
|
const cell = v.at(r, c);
|
|
if (isErr(cell)) return cell;
|
|
if (typeof cell === "boolean") out.push(cell);
|
|
else if (isFrac(cell)) out.push(cmp(cell, ZERO) !== 0);
|
|
}
|
|
}
|
|
} else if (isErr(v)) {
|
|
return v;
|
|
} else if (v !== null) {
|
|
const b = toBool(v);
|
|
if (isErr(b)) return b;
|
|
out.push(b);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ── 날짜 일련번호(엑셀 1900 체계 · 1970-01-01 = 25569) ───────────────────────
|
|
// 1900 을 윤년 취급하는 옛 버그는 그대로 두지 않음(1900 년대 자체를 실무가 안 씀) —
|
|
// 유닉스 자정 기준으로 바로 셈해 1900 년 2 월 안쪽만 어긋남(1단계 밖).
|
|
|
|
const MS_PER_DAY = 86400000;
|
|
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
|
|
|
|
function serialToDate(serial: number): Date {
|
|
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
|
|
}
|
|
|
|
function dateToSerial(d: Date): number {
|
|
return d.getTime() / MS_PER_DAY + UNIX_EPOCH_AS_EXCEL_SERIAL;
|
|
}
|
|
|
|
/** DATEDIF 낱자리(Y·M·D·YM·MD·YD) — 끝이 처음보다 이르면 `#NUM!` */
|
|
function dateDiff(startSerial: number, endSerial: number, unit: string): number | ErrorValue {
|
|
const start = serialToDate(startSerial);
|
|
const end = serialToDate(endSerial);
|
|
if (end.getTime() < start.getTime()) return mkErr("#NUM!");
|
|
const u = unit.toUpperCase();
|
|
const sy = start.getUTCFullYear();
|
|
const sm = start.getUTCMonth();
|
|
const sd = start.getUTCDate();
|
|
const ey = end.getUTCFullYear();
|
|
const em = end.getUTCMonth();
|
|
const ed = end.getUTCDate();
|
|
if (u === "Y") {
|
|
let y = ey - sy;
|
|
if (em < sm || (em === sm && ed < sd)) y--;
|
|
return y;
|
|
}
|
|
if (u === "M") {
|
|
let m = (ey - sy) * 12 + (em - sm);
|
|
if (ed < sd) m--;
|
|
return m;
|
|
}
|
|
if (u === "D") return Math.round((end.getTime() - start.getTime()) / MS_PER_DAY);
|
|
if (u === "YM") {
|
|
let m = em - sm;
|
|
if (ed < sd) m--;
|
|
return ((m % 12) + 12) % 12;
|
|
}
|
|
if (u === "MD") {
|
|
let d = ed - sd;
|
|
if (d < 0) d += new Date(Date.UTC(ey, em, 0)).getUTCDate();
|
|
return d;
|
|
}
|
|
if (u === "YD") {
|
|
let days = Math.round((end.getTime() - Date.UTC(ey, sm, sd)) / MS_PER_DAY);
|
|
if (days < 0) days = Math.round((end.getTime() - Date.UTC(ey - 1, sm, sd)) / MS_PER_DAY);
|
|
return days;
|
|
}
|
|
return mkErr("#NUM!");
|
|
}
|
|
|
|
/** 일련번호의 소수부(하루 안 시각) → 시 · 분 · 초 */
|
|
function timeParts(serial: number): { h: number; mnt: number; s: number } {
|
|
const dayFrac = serial - Math.floor(serial);
|
|
const totalSeconds = Math.round(dayFrac * 86400);
|
|
return {
|
|
h: Math.floor(totalSeconds / 3600) % 24,
|
|
mnt: Math.floor(totalSeconds / 60) % 60,
|
|
s: totalSeconds % 60,
|
|
};
|
|
}
|
|
|
|
// ── 함수 표 ──────────────────────────────────────────────────────────────
|
|
|
|
export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
|
|
IF: {
|
|
min: 2,
|
|
max: 3,
|
|
call: (args) => {
|
|
const c = bool(args[0]());
|
|
if (isErr(c)) return c;
|
|
if (c) return args[1]();
|
|
return args.length > 2 ? args[2]() : false;
|
|
},
|
|
},
|
|
AND: {
|
|
min: 1,
|
|
call: (args) => {
|
|
const bs = collectBools(args);
|
|
if (isErr(bs)) return bs;
|
|
if (bs.length === 0) return mkErr("#VALUE!");
|
|
return bs.every(Boolean);
|
|
},
|
|
},
|
|
OR: {
|
|
min: 1,
|
|
call: (args) => {
|
|
const bs = collectBools(args);
|
|
if (isErr(bs)) return bs;
|
|
if (bs.length === 0) return mkErr("#VALUE!");
|
|
return bs.some(Boolean);
|
|
},
|
|
},
|
|
NOT: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const b = bool(args[0]());
|
|
return isErr(b) ? b : !b;
|
|
},
|
|
},
|
|
IFERROR: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const v = args[0]();
|
|
if (!isRange(v) && isErr(v)) return args[1]();
|
|
return v;
|
|
},
|
|
},
|
|
CONCATENATE: {
|
|
min: 1,
|
|
call: (args) => {
|
|
let out = "";
|
|
for (const a of args) {
|
|
const v = a();
|
|
if (isRange(v)) {
|
|
for (let r = 0; r < v.rows; r++) {
|
|
for (let c = 0; c < v.cols; c++) {
|
|
const t = toText(v.at(r, c));
|
|
if (isErr(t)) return t;
|
|
out += t;
|
|
}
|
|
}
|
|
} else {
|
|
const t = str(v);
|
|
if (isErr(t)) return t;
|
|
out += t;
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
},
|
|
FIXED: {
|
|
min: 1,
|
|
max: 3,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
if (isErr(n)) return n;
|
|
const decArg = args.length > 1 ? num(args[1]()) : frac(2n);
|
|
if (isErr(decArg)) return decArg;
|
|
const decimals = Math.trunc(fracToDouble(decArg));
|
|
const noCommaArg = args.length > 2 ? bool(args[2]()) : false;
|
|
if (isErr(noCommaArg)) return noCommaArg;
|
|
return formatFixed(n, decimals, !noCommaArg);
|
|
},
|
|
},
|
|
TEXT: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const v = toScalar(args[0]());
|
|
if (isErr(v)) return v;
|
|
const code = str(args[1]());
|
|
if (isErr(code)) return code;
|
|
return formatValue(v, code).글;
|
|
},
|
|
},
|
|
LEN: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
return isErr(s) ? s : frac(BigInt(s.length));
|
|
},
|
|
},
|
|
LEFT: {
|
|
min: 1,
|
|
max: 2,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
if (isErr(s)) return s;
|
|
const nArg = args.length > 1 ? num(args[1]()) : ONE;
|
|
if (isErr(nArg)) return nArg;
|
|
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
|
|
return s.slice(0, n);
|
|
},
|
|
},
|
|
RIGHT: {
|
|
min: 1,
|
|
max: 2,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
if (isErr(s)) return s;
|
|
const nArg = args.length > 1 ? num(args[1]()) : ONE;
|
|
if (isErr(nArg)) return nArg;
|
|
const n = Math.max(0, Math.trunc(fracToDouble(nArg)));
|
|
return n === 0 ? "" : s.slice(-n);
|
|
},
|
|
},
|
|
MID: {
|
|
min: 3,
|
|
max: 3,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
if (isErr(s)) return s;
|
|
const startN = num(args[1]());
|
|
if (isErr(startN)) return startN;
|
|
const lenN = num(args[2]());
|
|
if (isErr(lenN)) return lenN;
|
|
const start = Math.trunc(fracToDouble(startN));
|
|
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
|
|
if (start < 1) return mkErr("#VALUE!");
|
|
return s.slice(start - 1, start - 1 + len);
|
|
},
|
|
},
|
|
VALUE: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
if (isErr(s)) return s;
|
|
const trimmed = s.trim();
|
|
const pct = trimmed.endsWith("%");
|
|
const body = pct ? trimmed.slice(0, -1) : trimmed;
|
|
try {
|
|
const v = parseDecimal(body);
|
|
return pct ? { n: v.n, d: v.d * 100n } : v;
|
|
} catch {
|
|
return mkErr("#VALUE!", `글을 수로 못 읽음: ${s}`);
|
|
}
|
|
},
|
|
},
|
|
TEXTJOIN: {
|
|
min: 3,
|
|
call: (args) => {
|
|
const delim = str(args[0]());
|
|
if (isErr(delim)) return delim;
|
|
const ignoreEmpty = bool(args[1]());
|
|
if (isErr(ignoreEmpty)) return ignoreEmpty;
|
|
const parts: string[] = [];
|
|
for (let i = 2; i < args.length; i++) {
|
|
const v = args[i]();
|
|
if (isRange(v)) {
|
|
for (let r = 0; r < v.rows; r++) {
|
|
for (let c = 0; c < v.cols; c++) {
|
|
const t = toText(v.at(r, c));
|
|
if (isErr(t)) return t;
|
|
if (!(ignoreEmpty && t === "")) parts.push(t);
|
|
}
|
|
}
|
|
} else {
|
|
const t = str(v);
|
|
if (isErr(t)) return t;
|
|
if (!(ignoreEmpty && t === "")) parts.push(t);
|
|
}
|
|
}
|
|
return parts.join(delim);
|
|
},
|
|
},
|
|
TRIM: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
return isErr(s) ? s : s.split(" ").filter(Boolean).join(" ");
|
|
},
|
|
},
|
|
UPPER: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
return isErr(s) ? s : s.toUpperCase();
|
|
},
|
|
},
|
|
LOWER: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
return isErr(s) ? s : s.toLowerCase();
|
|
},
|
|
},
|
|
SUBSTITUTE: {
|
|
min: 3,
|
|
max: 4,
|
|
call: (args) => {
|
|
const text = str(args[0]());
|
|
if (isErr(text)) return text;
|
|
const oldT = str(args[1]());
|
|
if (isErr(oldT)) return oldT;
|
|
const newT = str(args[2]());
|
|
if (isErr(newT)) return newT;
|
|
if (oldT === "") return text;
|
|
if (args.length <= 3) return text.split(oldT).join(newT);
|
|
const nthArg = num(args[3]());
|
|
if (isErr(nthArg)) return nthArg;
|
|
const nth = Math.trunc(fracToDouble(nthArg));
|
|
let idx = -1;
|
|
let count = 0;
|
|
let pos = 0;
|
|
for (;;) {
|
|
const found = text.indexOf(oldT, pos);
|
|
if (found < 0) break;
|
|
count++;
|
|
if (count === nth) {
|
|
idx = found;
|
|
break;
|
|
}
|
|
pos = found + oldT.length;
|
|
}
|
|
return idx < 0 ? text : text.slice(0, idx) + newT + text.slice(idx + oldT.length);
|
|
},
|
|
},
|
|
REPLACE: {
|
|
min: 4,
|
|
max: 4,
|
|
call: (args) => {
|
|
const text = str(args[0]());
|
|
if (isErr(text)) return text;
|
|
const startN = num(args[1]());
|
|
if (isErr(startN)) return startN;
|
|
const lenN = num(args[2]());
|
|
if (isErr(lenN)) return lenN;
|
|
const newText = str(args[3]());
|
|
if (isErr(newText)) return newText;
|
|
const start = Math.trunc(fracToDouble(startN));
|
|
const len = Math.max(0, Math.trunc(fracToDouble(lenN)));
|
|
if (start < 1) return mkErr("#VALUE!");
|
|
return text.slice(0, start - 1) + newText + text.slice(start - 1 + len);
|
|
},
|
|
},
|
|
FIND: {
|
|
min: 2,
|
|
max: 3,
|
|
call: (args) => {
|
|
const findT = str(args[0]());
|
|
if (isErr(findT)) return findT;
|
|
const within = str(args[1]());
|
|
if (isErr(within)) return within;
|
|
const startArg = args.length > 2 ? num(args[2]()) : ONE;
|
|
if (isErr(startArg)) return startArg;
|
|
const start = Math.trunc(fracToDouble(startArg));
|
|
if (start < 1) return mkErr("#VALUE!");
|
|
const idx = within.indexOf(findT, start - 1);
|
|
return idx < 0 ? mkErr("#VALUE!") : frac(BigInt(idx + 1));
|
|
},
|
|
},
|
|
SEARCH: {
|
|
min: 2,
|
|
max: 3,
|
|
call: (args) => {
|
|
const findT = str(args[0]());
|
|
if (isErr(findT)) return findT;
|
|
const within = str(args[1]());
|
|
if (isErr(within)) return within;
|
|
const startArg = args.length > 2 ? num(args[2]()) : ONE;
|
|
if (isErr(startArg)) return startArg;
|
|
const start = Math.trunc(fracToDouble(startArg));
|
|
if (start < 1) return mkErr("#VALUE!");
|
|
const pat = wildcardToSearchRegex(findT);
|
|
const m = pat.exec(within.slice(start - 1));
|
|
return m ? frac(BigInt(m.index + start)) : mkErr("#VALUE!");
|
|
},
|
|
},
|
|
REPT: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const s = str(args[0]());
|
|
if (isErr(s)) return s;
|
|
const nArg = num(args[1]());
|
|
if (isErr(nArg)) return nArg;
|
|
const n = Math.trunc(fracToDouble(nArg));
|
|
return n < 0 ? mkErr("#VALUE!") : s.repeat(n);
|
|
},
|
|
},
|
|
ISNUMBER: { min: 1, max: 1, call: (args) => isFrac(toScalar(args[0]())) },
|
|
ISTEXT: { min: 1, max: 1, call: (args) => typeof toScalar(args[0]()) === "string" },
|
|
ISBLANK: { min: 1, max: 1, call: (args) => toScalar(args[0]()) === null },
|
|
ISERROR: { min: 1, max: 1, call: (args) => isErr(toScalar(args[0]())) },
|
|
NA: { min: 0, max: 0, call: () => mkErr("#N/A") },
|
|
TODAY: {
|
|
min: 0,
|
|
max: 0,
|
|
call: () => {
|
|
const now = new Date();
|
|
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
|
|
return fromDouble(dateToSerial(new Date(midnight)));
|
|
},
|
|
},
|
|
DATE: {
|
|
min: 3,
|
|
max: 3,
|
|
call: (args) => {
|
|
const y = num(args[0]());
|
|
if (isErr(y)) return y;
|
|
const m = num(args[1]());
|
|
if (isErr(m)) return m;
|
|
const d = num(args[2]());
|
|
if (isErr(d)) return d;
|
|
const date = new Date(
|
|
Date.UTC(
|
|
Math.trunc(fracToDouble(y)),
|
|
Math.trunc(fracToDouble(m)) - 1,
|
|
Math.trunc(fracToDouble(d)),
|
|
),
|
|
);
|
|
return fromDouble(dateToSerial(date));
|
|
},
|
|
},
|
|
YEAR: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCFullYear()));
|
|
},
|
|
},
|
|
MONTH: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCMonth() + 1));
|
|
},
|
|
},
|
|
DAY: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
return isErr(n) ? n : frac(BigInt(serialToDate(fracToDouble(n)).getUTCDate()));
|
|
},
|
|
},
|
|
NOW: { min: 0, max: 0, call: () => fromDouble(dateToSerial(new Date())) },
|
|
WEEKDAY: {
|
|
min: 1,
|
|
max: 2,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
if (isErr(n)) return n;
|
|
const typeArg = args.length > 1 ? num(args[1]()) : ONE;
|
|
if (isErr(typeArg)) return typeArg;
|
|
const type = Math.trunc(fracToDouble(typeArg));
|
|
const dow = serialToDate(fracToDouble(n)).getUTCDay(); // 0=일 .. 6=토
|
|
if (type === 2) return frac(BigInt(((dow + 6) % 7) + 1));
|
|
if (type === 3) return frac(BigInt((dow + 6) % 7));
|
|
return frac(BigInt(dow + 1));
|
|
},
|
|
},
|
|
EDATE: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
if (isErr(n)) return n;
|
|
const mArg = num(args[1]());
|
|
if (isErr(mArg)) return mArg;
|
|
const months = Math.trunc(fracToDouble(mArg));
|
|
const d = serialToDate(fracToDouble(n));
|
|
const targetMonth = d.getUTCMonth() + months;
|
|
// 달 끝을 넘는 날짜(1/31 + 1개월 등)는 다음 달로 넘기지 않고 그 달 말일로 눌러 담음(엑셀과 같음).
|
|
const lastDay = new Date(Date.UTC(d.getUTCFullYear(), targetMonth + 1, 0)).getUTCDate();
|
|
const day = Math.min(d.getUTCDate(), lastDay);
|
|
const date = new Date(Date.UTC(d.getUTCFullYear(), targetMonth, day));
|
|
return fromDouble(dateToSerial(date));
|
|
},
|
|
},
|
|
EOMONTH: {
|
|
min: 2,
|
|
max: 2,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
if (isErr(n)) return n;
|
|
const mArg = num(args[1]());
|
|
if (isErr(mArg)) return mArg;
|
|
const months = Math.trunc(fracToDouble(mArg));
|
|
const d = serialToDate(fracToDouble(n));
|
|
const date = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + months + 1, 0));
|
|
return fromDouble(dateToSerial(date));
|
|
},
|
|
},
|
|
DATEDIF: {
|
|
min: 3,
|
|
max: 3,
|
|
call: (args) => {
|
|
const s = num(args[0]());
|
|
if (isErr(s)) return s;
|
|
const e = num(args[1]());
|
|
if (isErr(e)) return e;
|
|
const unit = str(args[2]());
|
|
if (isErr(unit)) return unit;
|
|
const result = dateDiff(fracToDouble(s), fracToDouble(e), unit);
|
|
return isErr(result) ? result : frac(BigInt(result));
|
|
},
|
|
},
|
|
NETWORKDAYS: {
|
|
min: 2,
|
|
max: 3,
|
|
call: (args) => {
|
|
const s = num(args[0]());
|
|
if (isErr(s)) return s;
|
|
const e = num(args[1]());
|
|
if (isErr(e)) return e;
|
|
let startSerial = Math.round(fracToDouble(s));
|
|
let endSerial = Math.round(fracToDouble(e));
|
|
let sign = 1;
|
|
if (startSerial > endSerial) {
|
|
[startSerial, endSerial] = [endSerial, startSerial];
|
|
sign = -1;
|
|
}
|
|
const holidays = new Set<number>();
|
|
if (args.length > 2) {
|
|
const holidayArg = args[2]();
|
|
if (isRange(holidayArg)) {
|
|
for (let r = 0; r < holidayArg.rows; r++) {
|
|
for (let c = 0; c < holidayArg.cols; c++) {
|
|
const cell = holidayArg.at(r, c);
|
|
if (isFrac(cell)) holidays.add(Math.round(fracToDouble(cell)));
|
|
}
|
|
}
|
|
} else if (isFrac(holidayArg)) {
|
|
holidays.add(Math.round(fracToDouble(holidayArg)));
|
|
}
|
|
}
|
|
let count = 0;
|
|
for (let day = startSerial; day <= endSerial; day++) {
|
|
const dow = serialToDate(day).getUTCDay();
|
|
if (dow === 0 || dow === 6) continue;
|
|
if (holidays.has(day)) continue;
|
|
count++;
|
|
}
|
|
return frac(BigInt(sign * count));
|
|
},
|
|
},
|
|
HOUR: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).h));
|
|
},
|
|
},
|
|
MINUTE: {
|
|
min: 1,
|
|
max: 1,
|
|
call: (args) => {
|
|
const n = num(args[0]());
|
|
return isErr(n) ? n : frac(BigInt(timeParts(fracToDouble(n)).mnt));
|
|
},
|
|
},
|
|
TIME: {
|
|
min: 3,
|
|
max: 3,
|
|
call: (args) => {
|
|
const hArg = num(args[0]());
|
|
if (isErr(hArg)) return hArg;
|
|
const mArg = num(args[1]());
|
|
if (isErr(mArg)) return mArg;
|
|
const sArg = num(args[2]());
|
|
if (isErr(sArg)) return sArg;
|
|
const totalSeconds =
|
|
Math.trunc(fracToDouble(hArg)) * 3600 +
|
|
Math.trunc(fracToDouble(mArg)) * 60 +
|
|
Math.trunc(fracToDouble(sArg));
|
|
const dayFraction = ((totalSeconds % 86400) + 86400) % 86400;
|
|
return fromDouble(dayFraction / 86400);
|
|
},
|
|
},
|
|
};
|