feat(spreadsheet): 통계 함수 13개 · 날짜 함수 9개 · numfmt 날짜 형식

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
This commit is contained in:
2026-09-27 21:18:23 +09:00
co-authored by Claude Sonnet 5
parent 015c35ad0e
commit 4804caa9bc
5 changed files with 672 additions and 4 deletions
@@ -1,8 +1,9 @@
/* =============================================================================
* spreadsheet_functions.ts (주인 B)
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` 에
* 두고 여기서 모음. 1단계 약 45 + 2단계 첫 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2`(sub1 의
* `spreadsheet_func_help.ts` 가 읽어 이름 목록에 더할 것 — 그 파일은 sub1 몫이라 안 고침).
* 함수 표 — 이름(대문자) → 뜻. 몸은 `spreadsheet_functions_math.ts` · `_misc.ts` · `_lookup.ts` ·
* `_stat.ts` 에 두고 여기서 모음. 1단계 약 45 + 2단계 묶음(2026-09-27) — 아래 `FUNC_HELP_BATCH2` ·
* `FUNC_HELP_BATCH3`(sub1 의 `spreadsheet_func_help.ts` 가 읽어 이름 목록에 둘 다 더할 것 —
* 그 파일은 sub1 몫이라 안 고침).
* 없는 이름 = `#NAME?` · 인자 수가 min · max 밖이면 A 의 eval 이 `#VALUE!` + 까닭(엑셀은 입력을 막음).
* ========================================================================== */
@@ -10,11 +11,13 @@ import type { SheetFunction } from "./spreadsheet_types";
import { MATH_FUNCTIONS } from "./spreadsheet_functions_math";
import { MISC_FUNCTIONS } from "./spreadsheet_functions_misc";
import { LOOKUP_FUNCTIONS } from "./spreadsheet_functions_lookup";
import { STAT_FUNCTIONS } from "./spreadsheet_functions_stat";
const TABLE: Record<string, SheetFunction> = {
...MATH_FUNCTIONS,
...MISC_FUNCTIONS,
...LOOKUP_FUNCTIONS,
...STAT_FUNCTIONS,
};
export function getFunction(name: string): SheetFunction | undefined {
@@ -119,3 +122,33 @@ export const FUNC_HELP_BATCH2: FuncHelpEntry[] = [
{ 이름: "MONTH", 꼴: "MONTH(일련번호)", 뜻: "월" },
{ 이름: "DAY", 꼴: "DAY(일련번호)", 뜻: "일" },
];
/** 2단계 셋째 묶음(2026-09-27) — 위 `FUNC_HELP_BATCH2` 와 같은 모양 · 같이 sub1 표에 더할 것. */
export const FUNC_HELP_BATCH3: FuncHelpEntry[] = [
{ 이름: "MEDIAN", 꼴: "MEDIAN(수1, [수2], …)", 뜻: "가운데 값" },
{ 이름: "MODE", 꼴: "MODE(수1, [수2], …)", 뜻: "가장 자주 나오는 값" },
{ 이름: "STDEV", 꼴: "STDEV(수1, [수2], …)", 뜻: "표본 표준편차" },
{ 이름: "STDEV.S", 꼴: "STDEV.S(수1, [수2], …)", 뜻: "표본 표준편차" },
{ 이름: "STDEV.P", 꼴: "STDEV.P(수1, [수2], …)", 뜻: "모집단 표준편차" },
{ 이름: "VAR", 꼴: "VAR(수1, [수2], …)", 뜻: "표본 분산" },
{ 이름: "RANK", 꼴: "RANK(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
{ 이름: "RANK.EQ", 꼴: "RANK.EQ(수, 범위, [순서=0])", 뜻: "범위 안 순위(0=내림차순)" },
{ 이름: "LARGE", 꼴: "LARGE(범위, k)", 뜻: "k 번째로 큰 값" },
{ 이름: "SMALL", 꼴: "SMALL(범위, k)", 뜻: "k 번째로 작은 값" },
{ 이름: "PERCENTILE", 꼴: "PERCENTILE(범위, k)", 뜻: "k(0~1) 자리 백분위 값" },
{ 이름: "QUARTILE", 꼴: "QUARTILE(범위, 사분위수)", 뜻: "사분위수(0~4)" },
{ 이름: "COUNTBLANK", 꼴: "COUNTBLANK(범위)", 뜻: "빈 칸 개수" },
{ 이름: "NOW", 꼴: "NOW()", 뜻: "지금 날짜 · 시각(일련번호)" },
{ 이름: "WEEKDAY", 꼴: "WEEKDAY(일련번호, [유형=1])", 뜻: "요일 번호" },
{ 이름: "EDATE", 꼴: "EDATE(시작일, 개월수)", 뜻: "개월 뒤(앞) 같은 날" },
{ 이름: "EOMONTH", 꼴: "EOMONTH(시작일, 개월수)", 뜻: "개월 뒤(앞) 달의 말일" },
{ 이름: "DATEDIF", 꼴: "DATEDIF(시작일, 종료일, 단위)", 뜻: "두 날짜 차(단위 Y·M·D·YM·MD·YD)" },
{
이름: "NETWORKDAYS",
꼴: "NETWORKDAYS(시작일, 종료일, [휴일범위])",
뜻: "평일 일수(토·일 · 휴일 뺌)",
},
{ 이름: "HOUR", 꼴: "HOUR(일련번호)", 뜻: "시" },
{ 이름: "MINUTE", 꼴: "MINUTE(일련번호)", 뜻: "분" },
{ 이름: "TIME", 꼴: "TIME(시, 분, 초)", 뜻: "시각 일련번호를 만듦" },
];
@@ -102,6 +102,58 @@ 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> = {
@@ -466,4 +518,139 @@ export const MISC_FUNCTIONS: Record<string, SheetFunction> = {
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);
},
},
};
@@ -0,0 +1,228 @@
/* =============================================================================
* spreadsheet_functions_stat.ts (주인 B · 3차 · 2026-09-27)
* 통계 함수 — MEDIAN · MODE · STDEV(.S/.P) · VAR · RANK(.EQ) · LARGE · SMALL ·
* PERCENTILE · QUARTILE · COUNTBLANK. 형 바꾸기 도우미는 `spreadsheet_functions_math.ts` 공용 것을 씀.
* ========================================================================== */
import { ZERO, add, cmp, div, frac, mul, sub, type Frac } from "@ui/sheet/ui_template_sheet_frac";
import type {
ErrorValue,
EvalContext,
EvalResult,
LazyArg,
RangeValue,
SheetFunction,
} from "./spreadsheet_types";
import {
collectNumbers,
fracToDouble,
fromDouble,
isErr,
isFrac,
isRange,
mkErr,
num,
} from "./spreadsheet_functions_math";
function rangeNumbers(range: RangeValue): Frac[] | ErrorValue {
const out: Frac[] = [];
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
const cell = range.at(r, c);
if (isErr(cell)) return cell;
if (isFrac(cell)) out.push(cell);
}
}
return out;
}
function meanAndSumSq(values: Frac[]): { mean: Frac; sumSq: Frac } {
const n = frac(BigInt(values.length));
const mean = div(values.reduce(add, ZERO), n);
const sumSq = values.reduce((acc, v) => add(acc, mul(sub(v, mean), sub(v, mean))), ZERO);
return { mean, sumSq };
}
function sampleVariance(values: Frac[]): Frac | ErrorValue {
if (values.length < 2) return mkErr("#DIV/0!");
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length - 1)));
}
function populationVariance(values: Frac[]): Frac | ErrorValue {
if (values.length < 1) return mkErr("#DIV/0!");
return div(meanAndSumSq(values).sumSq, frac(BigInt(values.length)));
}
function stdevOf(variance: Frac | ErrorValue): Frac | ErrorValue {
if (isErr(variance)) return variance;
return fromDouble(Math.sqrt(fracToDouble(variance)));
}
/** 정렬한 값에서 0 ≤ k ≤ 1 자리의 백분위(직선 보간 · 엑셀 PERCENTILE.INC 와 같음) */
function percentileOf(values: Frac[], k: number): Frac | ErrorValue {
if (values.length === 0) return mkErr("#NUM!");
if (k < 0 || k > 1) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
const pos = k * (sorted.length - 1);
const lowIdx = Math.floor(pos);
const highIdx = Math.ceil(pos);
if (lowIdx === highIdx) return sorted[lowIdx];
const weight = fromDouble(pos - lowIdx);
if (isErr(weight)) return weight;
return add(sorted[lowIdx], mul(weight, sub(sorted[highIdx], sorted[lowIdx])));
}
function nthOrderStat(range: RangeValue, k: number, largest: boolean): Frac | ErrorValue {
const values = rangeNumbers(range);
if (isErr(values)) return values;
if (k < 1 || k > values.length) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
return sorted[largest ? sorted.length - k : k - 1];
}
function rankCall(args: LazyArg[], _ctx: EvalContext): EvalResult {
const target = num(args[0]());
if (isErr(target)) return target;
const range = args[1]();
if (!isRange(range)) return mkErr("#VALUE!");
const orderArg = args.length > 2 ? num(args[2]()) : ZERO;
if (isErr(orderArg)) return orderArg;
const ascending = cmp(orderArg, ZERO) !== 0;
let rank = 1;
let found = false;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) {
const cell = range.at(r, c);
if (!isFrac(cell)) continue;
if (cmp(cell, target) === 0) found = true;
if (ascending ? cmp(cell, target) < 0 : cmp(cell, target) > 0) rank++;
}
}
return found ? frac(BigInt(rank)) : mkErr("#N/A");
}
export const STAT_FUNCTIONS: Record<string, SheetFunction> = {
MEDIAN: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
if (values.length === 0) return mkErr("#NUM!");
const sorted = [...values].sort((a, b) => cmp(a, b));
const mid = Math.floor(sorted.length / 2);
if (sorted.length % 2 === 1) return sorted[mid];
return div(add(sorted[mid - 1], sorted[mid]), frac(2n));
},
},
MODE: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
if (isErr(values)) return values;
let bestIdx = -1;
let bestCount = 1;
for (let i = 0; i < values.length; i++) {
let count = 0;
for (const v of values) if (cmp(values[i], v) === 0) count++;
if (count > bestCount) {
bestCount = count;
bestIdx = i;
}
}
return bestIdx < 0 ? mkErr("#N/A") : values[bestIdx];
},
},
STDEV: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(sampleVariance(values));
},
},
"STDEV.S": {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(sampleVariance(values));
},
},
"STDEV.P": {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : stdevOf(populationVariance(values));
},
},
VAR: {
min: 1,
call: (args) => {
const values = collectNumbers(args, true, true);
return isErr(values) ? values : sampleVariance(values);
},
},
RANK: { min: 2, max: 3, call: rankCall },
"RANK.EQ": { min: 2, max: 3, call: rankCall },
LARGE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), true);
},
},
SMALL: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
return nthOrderStat(range, Math.trunc(fracToDouble(kArg)), false);
},
},
PERCENTILE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const kArg = num(args[1]());
if (isErr(kArg)) return kArg;
const values = rangeNumbers(range);
if (isErr(values)) return values;
return percentileOf(values, fracToDouble(kArg));
},
},
QUARTILE: {
min: 2,
max: 2,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
const qArg = num(args[1]());
if (isErr(qArg)) return qArg;
const q = Math.trunc(fracToDouble(qArg));
if (q < 0 || q > 4) return mkErr("#NUM!");
const values = rangeNumbers(range);
if (isErr(values)) return values;
return percentileOf(values, q / 4);
},
},
COUNTBLANK: {
min: 1,
max: 1,
call: (args) => {
const range = args[0]();
if (!isRange(range)) return mkErr("#VALUE!");
let count = 0;
for (let r = 0; r < range.rows; r++) {
for (let c = 0; c < range.cols; c++) if (range.at(r, c) === null) count++;
}
return frac(BigInt(count));
},
},
};
+95 -1
View File
@@ -3,7 +3,8 @@
* 숫자 형식 코드 → 보이는 글 — 엑셀 코드 부분 집합: 일반 · `0` `#` `?` `.` `,`(천 단위 · 끝 쉼표 = 천으로 나눔) ·
* 구역 `;`(양 · 음 · 0 · 글) · `"글"` · `\x` · `_x`(폭만큼 띄움) · `*x`(채움) · `[Red]` 등 색 · `%` · `@` · `E+00`.
* 실무 형식(`0.00` · `_-* #,##0.00_-;\-* #,##0.00_-;_-* "-"_-;_-@_-` · `0.00_);[Red]\(0.00\)` …)은 모두 맞아야 함.
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 · 시간 형식은 1단계 밖(글 그대로).
* 끝수는 분수로 사사오입(엑셀 표시와 같음). 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`)도 봄
* (3차 · 2026-09-27) — 시간(`h:mm:ss`)은 아직 밖(글 그대로).
* ========================================================================== */
import {
@@ -364,6 +365,93 @@ function generalFormat(value: Frac): string {
return fracToString(value);
}
// ── 날짜 형식(`yyyy-mm-dd` · `m/d` · 요일 `aaa`/`aaaa`) ───────────────────────
// 엑셀 일련번호(1970-01-01 = 25569) → 달력. 1900 윤년 버그는 misc.ts 와 같이 안 다룸.
const MS_PER_DAY = 86400000;
const UNIX_EPOCH_AS_EXCEL_SERIAL = 25569;
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
function toDouble(x: Frac): number {
return Number(x.n) / Number(x.d);
}
function serialToDateUTC(serial: number): Date {
return new Date(Math.round((serial - UNIX_EPOCH_AS_EXCEL_SERIAL) * MS_PER_DAY));
}
type DatePart =
{ k: "lit"; text: string } | { k: "y" | "m" | "d"; width: 1 | 2 | 4 } | { k: "a"; width: 3 | 4 };
/** `y/m/d/a` 낱말 뜀(자리수가 아는 폭이 아니면 그대로 글로) — 없으면 날짜 형식 아님(null). */
function detectDateFormat(s: string): DatePart[] | null {
const parts: DatePart[] = [];
let hasDateToken = false;
let i = 0;
while (i < s.length) {
const ch = s[i];
if (ch === '"') {
let j = i + 1;
let text = "";
while (j < s.length && s[j] !== '"') {
text += s[j];
j++;
}
parts.push({ k: "lit", text });
i = j + 1;
continue;
}
if (ch === "\\" && i + 1 < s.length) {
parts.push({ k: "lit", text: s[i + 1] });
i += 2;
continue;
}
const lower = ch.toLowerCase();
if (lower === "y" || lower === "m" || lower === "d" || lower === "a") {
let j = i;
while (j < s.length && s[j].toLowerCase() === lower) j++;
const width = j - i;
if (lower === "a" && (width === 3 || width === 4)) {
parts.push({ k: "a", width });
hasDateToken = true;
} else if (lower !== "a" && (width === 1 || width === 2 || (lower === "y" && width === 4))) {
parts.push({ k: lower as "y" | "m" | "d", width: width as 1 | 2 | 4 });
hasDateToken = true;
} else {
parts.push({ k: "lit", text: s.slice(i, j) });
}
i = j;
continue;
}
parts.push({ k: "lit", text: ch });
i++;
}
return hasDateToken ? parts : null;
}
function renderDatePart(part: DatePart, date: Date): string {
if (part.k === "lit") return part.text;
if (part.k === "y") {
const y = date.getUTCFullYear();
return part.width === 4 ? String(y) : String(y % 100).padStart(2, "0");
}
if (part.k === "m") {
const m = date.getUTCMonth() + 1;
return part.width === 1 ? String(m) : String(m).padStart(2, "0");
}
if (part.k === "d") {
const d = date.getUTCDate();
return part.width === 1 ? String(d) : String(d).padStart(2, "0");
}
const wd = WEEKDAY_SHORT[date.getUTCDay()];
return part.width === 3 ? wd : `${wd}요일`;
}
function renderDateSection(parts: DatePart[], value: Frac): string {
const date = serialToDateUTC(toDouble(value));
return parts.map((p) => renderDatePart(p, date)).join("");
}
function chooseSection(sections: string[], value: Frac): { code: string; forceMinus: boolean } {
const isNeg = cmp(value, ZERO) < 0;
const isZero = cmp(value, ZERO) === 0;
@@ -385,6 +473,12 @@ function formatNumber(value: Frac, code: string | undefined): FormattedText {
const sections = splitSections(code);
const { code: chosen, forceMinus } = chooseSection(sections, value);
const { rest, color } = extractColor(chosen);
const datePlan = detectDateFormat(rest);
if (datePlan) {
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
return { 글: renderDateSection(datePlan, value), 정렬: "right", ...extra };
}
const { toks, fillChar } = tokenize(rest);
const extra: Partial<FormattedText> = {};
if (color) extra.색 = color;
@@ -294,6 +294,117 @@ errEq("NA", call("NA"), "#N/A");
fracEq("YEAR(TODAY()) = 지금 연도", call("YEAR", lit(todaySerial)), String(wantYear));
}
// ── 통계(3차) ────────────────────────────────────────────────────────────
fracEq("MEDIAN 홀수", call("MEDIAN", n(1), n(2), n(3), n(4), n(5)), "3");
fracEq("MEDIAN 짝수", call("MEDIAN", n(1), n(2), n(3), n(4)), "2.5");
fracEq("MODE 최빈값", call("MODE", n(1), n(2), n(2), n(3), n(3), n(3)), "3");
errEq("MODE 겹침 없음", call("MODE", n(1), n(2), n(3)), "#N/A");
fracEq("VAR 표본분산", call("VAR", n(1), n(2), n(3)), "1");
fracEq("STDEV 표본표준편차", call("STDEV", n(1), n(2), n(3)), "1");
fracEq("STDEV.S", call("STDEV.S", n(1), n(2), n(3)), "1");
fracEq("STDEV.P 모표준편차", call("STDEV.P", n(2), n(4), n(4), n(4), n(5), n(5), n(7), n(9)), "2");
{
const RANKS = range(1, 4, [[frac(10n), frac(20n), frac(30n), frac(40n)]]);
fracEq("RANK 내림차순(기본)", call("RANK", n(30), RANKS), "2");
fracEq("RANK 오름차순", call("RANK", n(30), RANKS, n(1)), "3");
fracEq("RANK.EQ", call("RANK.EQ", n(30), RANKS), "2");
errEq("RANK 없는 값", call("RANK", n(99), RANKS), "#N/A");
fracEq("LARGE 1위", call("LARGE", RANKS, n(1)), "40");
fracEq("LARGE 2위", call("LARGE", RANKS, n(2)), "30");
fracEq("SMALL 1위", call("SMALL", RANKS, n(1)), "10");
errEq("LARGE 범위밖", call("LARGE", RANKS, n(9)), "#NUM!");
}
{
const NUMS4 = range(1, 4, [[frac(1n), frac(2n), frac(3n), frac(4n)]]);
fracEq("PERCENTILE 중간", call("PERCENTILE", NUMS4, n("0.5")), "2.5");
fracEq("PERCENTILE 0", call("PERCENTILE", NUMS4, n(0)), "1");
fracEq("PERCENTILE 1", call("PERCENTILE", NUMS4, n(1)), "4");
fracEq("QUARTILE 중앙값(2)", call("QUARTILE", NUMS4, n(2)), "2.5");
fracEq("QUARTILE 최솟값(0)", call("QUARTILE", NUMS4, n(0)), "1");
errEq("QUARTILE 범위밖", call("QUARTILE", NUMS4, n(9)), "#NUM!");
}
fracEq("COUNTBLANK", call("COUNTBLANK", range(1, 4, [[frac(1n), null, "x", null]])), "2");
// ── 날짜 · 시간(3차) ──────────────────────────────────────────────────────
function dateSerial(y: number, m: number, day: number): EvalResult {
return call("DATE", n(y), n(m), n(day));
}
{
const serial = dateSerial(2026, 3, 15);
const wantDow = new Date(Date.UTC(2026, 2, 15)).getUTCDay(); // 0=일..6=토
fracEq("WEEKDAY 기본(1=일)", call("WEEKDAY", lit(serial)), String(wantDow + 1));
fracEq(
"WEEKDAY 유형2(1=월)",
call("WEEKDAY", lit(serial), n(2)),
String(((wantDow + 6) % 7) + 1),
);
}
{
// 월말 넘침 — 1/31 + 1개월은 다음 달로 안 넘고 그 달 말일(2/28)로.
const edated = call("EDATE", lit(dateSerial(2026, 1, 31)), n(1));
fracEq("EDATE 월말 눌러담음(월)", call("MONTH", lit(edated)), "2");
fracEq("EDATE 월말 눌러담음(일)", call("DAY", lit(edated)), "28");
const normal = call("EDATE", lit(dateSerial(2026, 1, 15)), n(1));
fracEq("EDATE 평범한 경우(일)", call("DAY", lit(normal)), "15");
}
{
const eom = call("EOMONTH", lit(dateSerial(2026, 2, 10)), n(0));
fracEq("EOMONTH 이번달 말일", call("DAY", lit(eom)), "28");
const eomNext = call("EOMONTH", lit(dateSerial(2026, 2, 10)), n(1));
fracEq("EOMONTH 다음달 말일", call("DAY", lit(eomNext)), "31");
}
fracEq(
"DATEDIF Y",
call("DATEDIF", lit(dateSerial(2020, 1, 1)), lit(dateSerial(2023, 1, 1)), txt("Y")),
"3",
);
fracEq(
"DATEDIF Y(생일 전)",
call("DATEDIF", lit(dateSerial(2020, 6, 15)), lit(dateSerial(2023, 1, 1)), txt("Y")),
"2",
);
fracEq(
"DATEDIF M",
call("DATEDIF", lit(dateSerial(2020, 1, 1)), lit(dateSerial(2023, 1, 1)), txt("M")),
"36",
);
fracEq(
"DATEDIF D",
call("DATEDIF", lit(dateSerial(2024, 1, 1)), lit(dateSerial(2024, 1, 31)), txt("D")),
"30",
);
errEq(
"DATEDIF 끝이 이름(#NUM!)",
call("DATEDIF", lit(dateSerial(2024, 1, 10)), lit(dateSerial(2024, 1, 1)), txt("D")),
"#NUM!",
);
{
const wantMon = new Date(Date.UTC(2024, 0, 1)).getUTCDay() === 1;
ok("2024-01-01 은 월요일(전제)", wantMon);
const nw = call("NETWORKDAYS", lit(dateSerial(2024, 1, 1)), lit(dateSerial(2024, 1, 7)));
fracEq("NETWORKDAYS 한 주", nw, "5");
const nwHoliday = call(
"NETWORKDAYS",
lit(dateSerial(2024, 1, 1)),
lit(dateSerial(2024, 1, 7)),
lit(dateSerial(2024, 1, 3)),
);
fracEq("NETWORKDAYS 휴일 뺌", nwHoliday, "4");
}
fracEq("HOUR", call("HOUR", lit(call("TIME", n(13), n(45), n(30)))), "13");
fracEq("MINUTE", call("MINUTE", lit(call("TIME", n(13), n(45), n(30)))), "45");
{
const t = call("TIME", n(6), n(0), n(0));
ok(
"TIME(6,0,0) = 0.25",
isFrac(t) && fracToString(t) === "0.25",
isFrac(t) ? fracToString(t) : String(t),
);
}
// ── 숫자 형식 ────────────────────────────────────────────────────────────
function fmt(value: Scalar, code: string): string {
@@ -346,6 +457,21 @@ ok(
);
ok("분수 분모 고정", fmt(frac(4n, 10n), "# ?/8") === " 3/8", fmt(frac(4n, 10n), "# ?/8"));
ok("분수 정확히 나눠떨어짐(정수만)", fmt(frac(3n), "# ?/?") === "3", fmt(frac(3n), "# ?/?"));
// 날짜 형식(3차)
{
const serial = dateSerial(2026, 3, 5) as Scalar;
ok("yyyy-mm-dd", fmt(serial, "yyyy-mm-dd") === "2026-03-05", fmt(serial, "yyyy-mm-dd"));
ok("m/d(자리 없는 달·일)", fmt(serial, "m/d") === "3/5", fmt(serial, "m/d"));
const wd = new Date(Date.UTC(2026, 2, 5)).getUTCDay();
const WEEKDAY_SHORT = ["일", "월", "화", "수", "목", "금", "토"];
ok(
"요일 aaa",
fmt(serial, "aaa") === WEEKDAY_SHORT[wd],
`got=${fmt(serial, "aaa")} want=${WEEKDAY_SHORT[wd]}`,
);
ok("요일 aaaa", fmt(serial, "aaaa") === `${WEEKDAY_SHORT[wd]}요일`, fmt(serial, "aaaa"));
}
ok("글자 형식 @", fmt("abc", "@@") === "abcabc", fmt("abc", "@@"));
ok(
"일반(General)",