diff --git a/README.en.md b/README.en.md
index a7ffbe0..8194923 100644
--- a/README.en.md
+++ b/README.en.md
@@ -44,7 +44,7 @@ right here, with the URL, the header and the data never passing through our serv
-
+
@@ -65,7 +65,7 @@ self-hosted, straight from PostgreSQL/MySQL — one saved read-only query, and n
and full-fidelity Excel/PDF export, where a chart exported to `.xlsx` is a real, editable chart
bound to its cells, because the OOXML chart parts are written by hand (ExcelJS writes none). Plus optional
bring-your-own-backend cloud save and live co-editing over it — presence, last-writer-wins with the loser told, and
-an undo that does not erase the other person's work. Bilingual UI (Thai/English), 2010 automated tests.
+an undo that does not erase the other person's work. Bilingual UI (Thai/English), 2013 automated tests.
---
@@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark
---
-### 🧪 What 2010 passing tests could not catch
+### 🧪 What 2013 passing tests could not catch
Every test of the assistant **mocks the model** — it returns what I imagined it would. Put a real
API key behind it, ask fourteen ordinary questions, and **six answers used functions this engine
@@ -120,7 +120,7 @@ Then **the first fix made it worse.** The rule started as "give the closest form
allows", so _"join all the names into one line"_ came back as `=SUM(A2:A20)` — `0` in the cell, no
error, nothing to notice. **A visible `#NAME?` traded for an invisible wrong number.**
-**And it happened again, in a different place.** With every gate green — 2010 tests, `axe` clean on
+**And it happened again, in a different place.** With every gate green — 2013 tests, `axe` clean on
both pages at two widths — an hour of clicking through the public build the way a first-time visitor
would found three things no gate can see:
@@ -395,7 +395,7 @@ Other available commands:
| `npm run build` | Build a production bundle |
| `npm run start` | Run the production build (run `npm run build` first) |
| `npm run lint` | Check code quality with ESLint |
-| `npm test` | Run the 2010-case Vitest suite |
+| `npm test` | Run the 2013-case Vitest suite |
| `npm run check:readme` | Check the READMEs still match the code (links/images/test count/new modules/both languages) |
| `npm run check:screens` | Figures printed on a screenshot still match the source |
| `npm run check:rls` | Two real accounts against your own Supabase: does the database refuse what the policies say it should (needs env) |
@@ -3041,7 +3041,7 @@ architecture behind it.
| `@anthropic-ai/sdk` | Connects to the Claude API for the AI assistant |
| `lucide-react` | UI icons |
| `clsx` | Conditional className composition |
-| `vitest` | Unit tests for the formula engine, sort logic, JSON-to-table conversion, pagination, rate limiting, templates, file fidelity, conditional formatting and live blocks (2010 cases) |
+| `vitest` | Unit tests for the formula engine, sort logic, JSON-to-table conversion, pagination, rate limiting, templates, file fidelity, conditional formatting and live blocks (2013 cases) |
> **Note:** No off-the-shelf formula library (e.g. HyperFormula) is used — the **formula engine is hand-written**
> (tokenizer, parser, evaluator, and functions) to keep full control over its behavior. See
@@ -3721,7 +3721,10 @@ unformatted.
**Elapsed time counts past a day, as Excel does (#219)** — `[h]`, `[mm]` and `[ss]` count the total hours, minutes or
seconds, not the time of day. 1.5 days in `[h]:mm` shows `36:00` (it used to show `12:00`), so a timesheet totalled in
-`[h]:mm` shows the real total, in the grid and in `TEXT` alike.
+`[h]:mm` shows the real total, in the grid and in `TEXT` alike. Zero and negative values show as Excel shows them
+too (checked in real Excel): 0 under `dd/mm/yyyy` is `00/01/1900` (it used to show `31/12/1899`, a date Excel never
+shows), a negative value under a date or time format shows `########` rather than an invented date, and `TEXT` of a
+negative value under a date or time code gives `#VALUE!`.
**Not supported:** `MATCH` over a two-dimensional range — that returns `#N/A` rather than guessing a
position inside a block. `MROUND` is not in the engine. Arithmetic that overflows (`10^308*10`) still
@@ -4093,13 +4096,13 @@ the framework bundle itself, which isn't a trade worth making here. Written down
## 🧪 Testing
```bash
-npm test # 2010 cases across 145 files, via Vitest
+npm test # 2013 cases across 145 files, via Vitest
```
Testing is focused on the **formula engine, sort logic, JSON-to-table conversion, pagination, rate-limit backoff, Excel templates and live-block placement** — pure functions with no React/DOM dependency, so
they run fast and give high confidence.
-**But not one of those 2010 cases opens the app**, and nearly every bug this project found by hand lived in
+**But not one of those 2013 cases opens the app**, and nearly every bug this project found by hand lived in
the wiring *between* pieces that all passed their tests — the toolbar's "+ row" called `addRow`, which
announced nothing, while `insertRowAtSelection` next to it announced correctly (both tested) · the AI
assistant sent a range including its text header, because the context builder read raw `sheet.cells`
@@ -4218,10 +4221,10 @@ once; disable `ArrowRight` in the grid and two assertions in the third fail. (Th
second one stayed green: the `case` I inserted landed *after* the existing `case "ArrowRight"` and was dead
code. Proving a gate means checking that the thing you meant to break actually broke.)
-> **2010 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock
+> **2013 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock
> the model, so it returns what the test author imagined. A test count says what you thought to ask,
> not whether you asked enough. Only a real API key found this: see
-> [What 2010 passing tests could not catch](#-what-2010-passing-tests-could-not-catch), repeatable
+> [What 2013 passing tests could not catch](#-what-2013-passing-tests-could-not-catch), repeatable
> with `npm run check:ai`.
| File | Cases | Tests |
@@ -4252,7 +4255,7 @@ code. Proving a gate means checking that the thing you meant to break actually b
| `moveRefs.test.ts` | 7 | Reference rewriting for a cut and paste (#51): references into the block follow it with their `$`, everything outside and an untouched formula's text stay as they were, a range moves only when all of it was cut, a sheet name is added across sheets, a moved formula keeps naming the sheet it came from, a reference to a cell the block landed on becomes `#REF!`, and a cut under a filter moves only the rows it took |
| `sheetSort.test.ts` | 13 | The bounds/header-detection heuristic, and sorting itself (blank values, limited column scope) · The header stays on top either way (#49): numbers under a word in any column, a label over an empty column, bold, a row with a number is not a header, a plain word list sorts whole |
| `jsonToTable.test.ts` | 14 | Finding the record array in a response, flattening nested objects, numeric-column detection, single-row KPI objects, an empty list in an envelope as no rows rather than a row of the envelope (#65), CSV codes keeping their leading zeros all the way to the sheet (#36) |
-| `excelDate.test.ts` | 17 | Dates as Excel keeps them (#45): 1900-system serials matching Excel, including its 29 Feb 1900; the ISO forms read as dates (others stay text); Excel date format codes (`dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`) and elapsed time past a day (`[h]:mm` of 1.5 days is `36:00`, #219) · Buddhist-Era dates (#82): every form in the issue as 15 Jan 2026, all twelve Thai months full, short and dotless, a two-digit year beside a Thai month, what must not be guessed (`2569`, `15/01/69`, `15/01/2024`), every Thai-calendar code plus `bbbb` and Thai month names |
+| `excelDate.test.ts` | 19 | Dates as Excel keeps them (#45): 1900-system serials matching Excel, including its 29 Feb 1900; the ISO forms read as dates (others stay text); Excel date format codes (`dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`) and elapsed time past a day (`[h]:mm` of 1.5 days is `36:00`, #219), 0 as `00/01/1900` and a negative value as `########` in the grid (#219) · Buddhist-Era dates (#82): every form in the issue as 15 Jan 2026, all twelve Thai months full, short and dotless, a two-digit year beside a Thai month, what must not be guessed (`2569`, `15/01/69`, `15/01/2024`), every Thai-calendar code plus `bbbb` and Thai month names |
| `dateConvert.test.ts` | 6 | "Convert to dates" (#82): a two-digit B.E. year is 25yy and an A.D. one follows Excel's window, month-first and year-first orders, dates already readable written as ISO, unreadable text left alone, a range planned without empty cells or formulas |
| `numberFormatCode.test.ts` | 6 | Excel number-format codes (#53): the issue's table (7%, 12.5%, $1,234.50, 1.235, 12,345), the old `0.00"%"` not multiplied, negative/zero sections, currency tags, scaling commas, literals between digits, and the fallback for fractions and scientific codes |
| `numberFormats.test.ts` | 5 | Percent, currency and decimals through a real .xlsx both ways (#53): a file's codes shown and exported unchanged, the app's Percent ×100 and exported as `0.00%`, and old sheets in the browser and the cloud (format 1) still reading 50.00% |
diff --git a/README.md b/README.md
index f987d96..5722908 100644
--- a/README.md
+++ b/README.md
@@ -40,7 +40,7 @@
-
+
@@ -61,7 +61,7 @@ and saying so when data came back incomplete. A range can be given a name (in Th
what it means, and a cell can carry a rule about what may go in it, refused before it is written rather than
flagged afterwards. Conditional formatting re-colours cells from their current values — comparisons,
top/bottom ranks, colour scales and data bars — and round-trips through Excel's own rule format. Bilingual UI
-(Thai/English), 2010 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
+(Thai/English), 2013 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
---
@@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel'
---
-### 🧪 สิ่งที่เทสต์ 2010 เคสจับไม่ได้
+### 🧪 สิ่งที่เทสต์ 2013 เคสจับไม่ได้
เทสต์ของผู้ช่วย AI ใช้ **mock ทั้งหมด** — มันตอบตามที่ผมคิดว่ามันจะตอบ พอเอา API key จริงมายิงคำถามธรรมดา
14 ข้อ **6 คำตอบใช้ฟังก์ชันที่เอนจินนี้ไม่มี** (`TEXTJOIN` `FIND` `RANK.EQ` `SUMPRODUCT` `CEILING` `CHAR`)
@@ -116,7 +116,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel'
มันจึงตอบคำถาม *"ต่อชื่อทั้งหมดเป็นบรรทัดเดียว"* ด้วย `=SUM(A2:A20)` ซึ่งได้ `0` โดยไม่มีอะไรบอกว่าผิด
**เปลี่ยน error ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น**
-**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2010 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า
+**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2013 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า
ที่สองความกว้าง — ผมลองไล่คลิก build สาธารณะแบบคนที่เพิ่งเข้ามาครั้งแรกอยู่ชั่วโมงหนึ่ง แล้วเจอสามอย่าง
ที่ไม่มีด่านไหนมองเห็น:
@@ -381,7 +381,7 @@ npm run dev
| `npm run build` | build เป็นเวอร์ชัน production |
| `npm run start` | รันเวอร์ชันที่ build แล้ว (ต้อง `npm run build` ก่อน) |
| `npm run lint` | ตรวจสอบคุณภาพโค้ดด้วย ESLint |
-| `npm test` | รัน unit test 2010 เคสด้วย Vitest |
+| `npm test` | รัน unit test 2013 เคสด้วย Vitest |
| `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) |
| `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม |
| `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) |
@@ -2644,7 +2644,7 @@ stack ของ `font-mono` จึงต่อท้ายด้วย Plex Sans
| `@anthropic-ai/sdk` | เชื่อมต่อ Claude API สำหรับผู้ช่วย AI |
| `lucide-react` | ไอคอน UI |
| `clsx` | รวม className แบบมีเงื่อนไข |
-| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2010 เคส) |
+| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2013 เคส) |
> **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง**
> ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ
@@ -3259,6 +3259,8 @@ error อยู่แล้ว คือ `IF` `IFERROR` `IFNA` `COUNT` `COUNTA`
**เวลาสะสมนับข้ามวันแบบ Excel (#219)** — รหัส `[h]` `[mm]` `[ss]` นับชั่วโมง นาที หรือวินาทีรวมทั้งหมด ไม่ใช่เวลาของวัน
1.5 วันในรูปแบบ `[h]:mm` ขึ้น `36:00` (เดิมขึ้น `12:00`) ใบลงเวลาที่รวมชั่วโมงด้วย `[h]:mm` จึงได้ยอดจริง ทั้งในตารางและใน `TEXT`
+· ค่า 0 กับค่าติดลบก็ขึ้นแบบ Excel (ลองใน Excel จริงแล้ว): 0 ในรูปแบบ `dd/mm/yyyy` ขึ้น `00/01/1900` (เดิมขึ้น `31/12/1899`
+ซึ่งเป็นวันที่ที่ Excel ไม่เคยแสดง) ค่าติดลบในรูปแบบวันที่หรือเวลาขึ้น `########` แทนวันที่ที่แต่งขึ้น และ `TEXT` ของค่าติดลบได้ `#VALUE!`
**ยังไม่รองรับ:** `MATCH` ต้องเป็นช่วงแถวเดียวหรือคอลัมน์เดียว ถ้าส่งช่วงสองมิติจะได้ `#N/A`
แทนการเดาตำแหน่งให้ · ยังไม่มี `MROUND` · การคำนวณที่ค่าล้น (`10^308*10`) ยังโชว์เป็น Infinity แทน `#NUM!`
@@ -3551,13 +3553,13 @@ B อ่านและแก้ได้ แต่ยังยึดเป็
## 🧪 การทดสอบ
```bash
-npm test # 2010 เคส ใน 145 ไฟล์ ด้วย Vitest
+npm test # 2013 เคส ใน 145 ไฟล์ ด้วย Vitest
```
โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM
จึงเทสต์ได้เร็วและมั่นใจได้สูง
-**แต่ 2010 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
+**แต่ 2013 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection`
ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน
`sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px
@@ -3657,9 +3659,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง
`case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง)
-> **2010 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
+> **2013 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
> มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม
-> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2010 เคสจับไม่ได้](#-สิ่งที่เทสต์-2010-เคสจับไม่ได้)
+> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2013 เคสจับไม่ได้](#-สิ่งที่เทสต์-2013-เคสจับไม่ได้)
> และรันซ้ำเองได้ด้วย `npm run check:ai`
| ไฟล์ | เคส | ทดสอบอะไร |
@@ -3690,7 +3692,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
| `moveRefs.test.ts` | 7 | การเขียนอ้างอิงใหม่ตอนตัดแล้ววาง (#51): อ้างอิงเข้าก้อนตามไปพร้อม `$`, นอกก้อนและข้อความสูตรที่ไม่ถูกแตะคงเดิม, ช่วงย้ายเมื่อถูกตัดทั้งก้อนเท่านั้น, ข้ามชีตใส่ชื่อชีตให้, สูตรที่ถูกย้ายยังชี้ชีตเดิม, อ้างเซลล์ที่ถูกวางทับเป็น `#REF!`, ตัดใต้ตัวกรองย้ายเฉพาะแถวที่ตัด |
| `sheetSort.test.ts` | 13 | ฮิวริสติกตรวจจับขอบเขต+หัวตาราง และการเรียงลำดับ (รวมกรณีค่าว่าง, จำกัดคอลัมน์ที่ย้าย) · หัวตารางอยู่บนทั้งสองทิศ (#49): ตัวเลขใต้คำในคอลัมน์ไหนก็ได้, ป้ายเหนือคอลัมน์ว่าง, ตัวหนา, แถวที่มีตัวเลขไม่ใช่หัว, รายการคำล้วนเรียงทั้งหมด |
| `jsonToTable.test.ts` | 14 | หา array ของ record ในการตอบกลับ, แตก object ซ้อนเป็นคอลัมน์, ตรวจจับคอลัมน์ตัวเลข, KPI object แถวเดียว, รายการว่างในซองได้ 0 แถว ไม่ใช่แถวของซอง (#65), CSV เก็บรหัสที่ขึ้นต้นด้วยศูนย์เป็นข้อความจนถึงตาราง (#36) |
-| `excelDate.test.ts` | 17 | วันที่แบบ Excel (#45): serial ระบบ 1900 ตรงกับ Excel รวม 29 ก.พ. 1900 ที่ Excel ถือว่ามี, รูปแบบ ISO ที่อ่านเป็นวันที่ (รูปอื่นเป็นข้อความ), รหัสรูปแบบวันที่ของ Excel (`dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`), เวลาสะสมเกินวัน (`[h]:mm` ของ 1.5 วันเป็น `36:00`, #219) · วันที่แบบ พ.ศ. (#82): ทุกรูปใน issue เป็น 15 ม.ค. 2026, เดือนไทยครบ 12 เดือนทั้งเต็ม/ย่อ/ไม่มีจุด, ปี 2 หลักคู่เดือนไทย, สิ่งที่ต้องไม่เดา (`2569`, `15/01/69`, `15/01/2024`), รหัสปฏิทินพุทธทุกแบบ + `bbbb` + ชื่อเดือนไทย |
+| `excelDate.test.ts` | 19 | วันที่แบบ Excel (#45): serial ระบบ 1900 ตรงกับ Excel รวม 29 ก.พ. 1900 ที่ Excel ถือว่ามี, รูปแบบ ISO ที่อ่านเป็นวันที่ (รูปอื่นเป็นข้อความ), รหัสรูปแบบวันที่ของ Excel (`dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`), เวลาสะสมเกินวัน (`[h]:mm` ของ 1.5 วันเป็น `36:00`, #219), 0 เป็น `00/01/1900` และค่าติดลบเป็น `########` ในตาราง (#219) · วันที่แบบ พ.ศ. (#82): ทุกรูปใน issue เป็น 15 ม.ค. 2026, เดือนไทยครบ 12 เดือนทั้งเต็ม/ย่อ/ไม่มีจุด, ปี 2 หลักคู่เดือนไทย, สิ่งที่ต้องไม่เดา (`2569`, `15/01/69`, `15/01/2024`), รหัสปฏิทินพุทธทุกแบบ + `bbbb` + ชื่อเดือนไทย |
| `dateConvert.test.ts` | 6 | "แปลงเป็นวันที่" (#82): ปี 2 หลัก พ.ศ. = 25yy, ค.ศ. ตามหน้าต่าง Excel, เดือน/วัน/ปี และปีก่อน, วันที่ที่อ่านได้อยู่แล้วเขียนเป็น ISO, สิ่งที่อ่านไม่ออกไม่ถูกแตะ, แผนของช่วงที่ไม่รวมช่องว่างและสูตร |
| `numberFormatCode.test.ts` | 6 | รหัสรูปแบบตัวเลขแบบ Excel (#53): ตารางจาก issue (7%, 12.5%, $1,234.50, 1.235, 12,345), `0.00"%"` แบบเดิมไม่ ×100, ส่วนติดลบ/ศูนย์, แท็กสกุลเงิน, จุลภาคหารพัน, ตัวอักษรคั่นกลางเลข และ fallback ของเศษส่วน/เลขวิทยาศาสตร์ |
| `numberFormats.test.ts` | 5 | เปอร์เซ็นต์/สกุลเงิน/ทศนิยมผ่าน .xlsx จริงทั้งไป-กลับ (#53): รหัสของไฟล์แสดงและส่งออกกลับตามเดิม, เปอร์เซ็นต์ของแอป ×100 และส่งออกเป็น `0.00%`, ชีตเก่าในเบราว์เซอร์และบนคลาวด์ (format 1) ยังขึ้น 50.00% |
diff --git a/public/screenshots/27-landing-stats.png b/public/screenshots/27-landing-stats.png
index 706e20e..8321550 100644
Binary files a/public/screenshots/27-landing-stats.png and b/public/screenshots/27-landing-stats.png differ
diff --git a/public/screenshots/en/27-landing-stats.png b/public/screenshots/en/27-landing-stats.png
index 6664c6d..081a6ce 100644
Binary files a/public/screenshots/en/27-landing-stats.png and b/public/screenshots/en/27-landing-stats.png differ
diff --git a/public/screenshots/screenshots.json b/public/screenshots/screenshots.json
index 8d01c30..b9ad01a 100644
--- a/public/screenshots/screenshots.json
+++ b/public/screenshots/screenshots.json
@@ -5,7 +5,7 @@
"27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
- "tests": 2010,
+ "tests": 2013,
"securityTests": 325
},
"38-landing-problems.png": {
@@ -21,7 +21,7 @@
"en/27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
- "tests": 2010,
+ "tests": 2013,
"securityTests": 325
},
"en/38-landing-problems.png": {
diff --git a/public/social-preview.png b/public/social-preview.png
index 59ed9e4..9352101 100644
Binary files a/public/social-preview.png and b/public/social-preview.png differ
diff --git a/src/app/opengraph-image.tsx b/src/app/opengraph-image.tsx
index 9d3c81c..88ff13f 100644
--- a/src/app/opengraph-image.tsx
+++ b/src/app/opengraph-image.tsx
@@ -29,7 +29,7 @@ export default function OgImage() {
const stats = [
["66", "engine functions"],
["37", "palette formulas"],
- ["2010", "automated tests"],
+ ["2013", "automated tests"],
["325", "of them security"],
["0", "formula libraries"],
];
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 39d7300..c6579d3 100644
--- a/src/i18n/en.ts
+++ b/src/i18n/en.ts
@@ -432,7 +432,7 @@ export const en: Messages = {
stats: [
{ value: "37", label: "ready-made formulas" },
{ value: "66", label: "engine functions" },
- { value: "2010", label: "automated tests" },
+ { value: "2013", label: "automated tests" },
{ value: "325", label: "security tests" },
{ value: "0", label: "formula libraries used" },
],
diff --git a/src/i18n/th.ts b/src/i18n/th.ts
index 5c8f918..5ac644b 100644
--- a/src/i18n/th.ts
+++ b/src/i18n/th.ts
@@ -432,7 +432,7 @@ export const th: Messages = {
stats: [
{ value: "37", label: "สูตรพร้อมใช้" },
{ value: "66", label: "ฟังก์ชันในเอนจิน" },
- { value: "2010", label: "เทสต์อัตโนมัติ" },
+ { value: "2013", label: "เทสต์อัตโนมัติ" },
{ value: "325", label: "เทสต์ด้านความปลอดภัย" },
{ value: "0", label: "ไลบรารีคำนวณสูตร" },
],
diff --git a/src/lib/cellFormat.ts b/src/lib/cellFormat.ts
index 0db866f..27d4b89 100644
--- a/src/lib/cellFormat.ts
+++ b/src/lib/cellFormat.ts
@@ -1,7 +1,7 @@
// Copyright 2026 Suruch Chakrapeesirisuk
// SPDX-License-Identifier: Apache-2.0
-import { BE_DATE_CODE, DEFAULT_DATE_CODE, formatSerial, isDateFormatCode, kindOfDateCode } from "./excelDate";
+import { BE_DATE_CODE, DEFAULT_DATE_CODE, formatSerial, isDateFormatCode, kindOfDateCode, NOT_A_DATE, showableAsDate } from "./excelDate";
import { formatNumberCode, isPercentCode } from "./numberFormatCode";
/**
@@ -90,7 +90,7 @@ export function pxToPt(px: number | undefined): number | undefined {
* (`numCode`) wins over the preset, so `0%` and `"$"#,##0.00` show as the file has them.
*/
export function formatNumberForDisplay(value: number, fmt: NumberFormat, dateCode?: string, numCode?: string): string {
- if (isDateFormat(fmt)) return formatSerial(value, dateCode ?? DEFAULT_DATE_CODE[fmt]);
+ if (isDateFormat(fmt)) return showableAsDate(value) ? formatSerial(value, dateCode ?? DEFAULT_DATE_CODE[fmt]) : NOT_A_DATE;
if (numCode) return formatNumberCode(value, numCode);
const fixed2 = (n: number) => n.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
switch (fmt) {
diff --git a/src/lib/excelDate.test.ts b/src/lib/excelDate.test.ts
index 857dead..7d731c6 100644
--- a/src/lib/excelDate.test.ts
+++ b/src/lib/excelDate.test.ts
@@ -3,6 +3,7 @@
import { describe, expect, it } from "vitest";
import { dateLiteral, formatSerial, isDateFormatCode, isoFromSerial, partsOfSerial, serialOf } from "./excelDate";
+import { formatNumberForDisplay } from "./cellFormat";
/**
* Dates as Excel keeps them (#45): a serial number of days, 1900 system, with the 29 February 1900
@@ -158,3 +159,17 @@ describe("a Buddhist-Era format shows the year Thai Excel does (#82)", () => {
for (const code of ["[$-107041E]d/m/yyyy;@", "dd/mm/bbbb", "[$-th-TH,107]d/m/yyyy"]) expect(isDateFormatCode(code), code).toBe(true);
});
});
+
+// Checked in Excel (#219): a cell holding 0 under dd/mm/yyyy shows 00/01/1900, and one holding a
+// negative number under a date or [h]:mm code is filled with #. It used to show 31/12/1899 and
+// 30/12/1899, dates Excel never shows.
+describe("zero and negative values under a date or time format (#219)", () => {
+ it("shows 0 as 00/01/1900 in the grid", () => {
+ expect(formatNumberForDisplay(0, "date", "dd/mm/yyyy")).toBe("00/01/1900");
+ });
+
+ it("fills a negative value with # in the grid instead of inventing a date", () => {
+ expect(formatNumberForDisplay(-1, "date", "dd/mm/yyyy")).toBe("########");
+ expect(formatNumberForDisplay(-1.5, "time", "[h]:mm")).toBe("########");
+ });
+});
diff --git a/src/lib/excelDate.ts b/src/lib/excelDate.ts
index 5845f9d..724de5d 100644
--- a/src/lib/excelDate.ts
+++ b/src/lib/excelDate.ts
@@ -302,6 +302,17 @@ function tokenizeWithLocale(code: string): { tokens: Token[]; locale: DateLocale
return { tokens: out, locale };
}
+/**
+ * Whether a serial can be shown under a date or time code. A negative one cannot (#219, checked in
+ * Excel): the grid fills the cell with `#` and `TEXT` gives `#VALUE!`, under the 1900 date system.
+ */
+export function showableAsDate(serial: number): boolean {
+ return Number.isFinite(serial) && serial >= 0;
+}
+
+/** What a cell shows for a value that cannot be a date, as Excel fills it at its default width. */
+export const NOT_A_DATE = "########";
+
/**
* A serial shown the way an Excel format code says: `dd/mm/yyyy`, `d-mmm-yy`, `h:mm AM/PM`.
*
@@ -312,7 +323,11 @@ function tokenizeWithLocale(code: string): { tokens: Token[]; locale: DateLocale
* year in any code (#82). Other locale tags (`[$-409]`) are read past.
*/
export function formatSerial(serial: number, code: string): string {
- const p = partsOfSerial(serial);
+ // Serial 0 is Excel's 0 January 1900 (`00/01/1900`), not the day before 1 January (#219,
+ // checked in Excel). Only the display moves: `partsOfSerial` still answers 31 December 1899 for
+ // the date functions and for the ISO text a cell keeps, which have no day 0 to write.
+ const zero = Math.floor(Math.round(serial * 86_400) / 86_400) === 0;
+ const p = zero ? { ...partsOfSerial(serial), year: 1900, month: 1, day: 0 } : partsOfSerial(serial);
const { tokens, locale } = tokenizeWithLocale(code);
const months = locale.thai ? THAI_MONTHS : MONTHS;
const shortMonth = (m: number) => (locale.thai ? THAI_MONTHS_SHORT[m - 1] : MONTHS[m - 1].slice(0, 3));
diff --git a/src/lib/formulaEngine/functions.test.ts b/src/lib/formulaEngine/functions.test.ts
index fddf6cf..b951b03 100644
--- a/src/lib/formulaEngine/functions.test.ts
+++ b/src/lib/formulaEngine/functions.test.ts
@@ -427,6 +427,16 @@ describe("TEXT writes the value the way its format says, as Excel does (#29)", (
expect(calc('TEXT(0.1,"[mm]:ss")')).toBe("144:00");
expect(calc('TEXT(0.5,"[s]")')).toBe("43200");
});
+
+ // Checked in Excel (#219): serial 0 is 0 January 1900, and a negative serial is no date at all.
+ it("shows serial 0 as Excel's 00/01/1900 and refuses a negative one with #VALUE!", () => {
+ expect(calc('TEXT(0,"dd/mm/yyyy")')).toBe("00/01/1900");
+ for (const f of ['TEXT(-1,"dd/mm/yyyy")', 'TEXT(-0.5,"hh:mm")', 'TEXT(-1,"[h]:mm")']) {
+ expect(String(calc(f)), f).toBe("#VALUE!");
+ }
+ // A negative number under a number code is still just a number.
+ expect(calc('TEXT(-1,"0.0")')).toBe("-1.0");
+ });
});
describe("the *IF(S) functions pass on an error they read, as Excel does (#96)", () => {
diff --git a/src/lib/formulaEngine/functions.ts b/src/lib/formulaEngine/functions.ts
index c6f8365..d456e5e 100644
--- a/src/lib/formulaEngine/functions.ts
+++ b/src/lib/formulaEngine/functions.ts
@@ -3,7 +3,7 @@
import { EvalResult, FormulaError, FormulaValue, flattenResult, isError, ERR_DIV0, ERR_NA, ERR_NUM, ERR_VALUE } from "./types";
import { toBoolean, toDisplayString, toNumber, isBlank } from "./coerce";
-import { dateLiteral, formatSerial, isDateFormatCode, partsOfSerial, serialOf } from "../excelDate";
+import { dateLiteral, formatSerial, isDateFormatCode, partsOfSerial, serialOf, showableAsDate } from "../excelDate";
import { formatNumberCode } from "../numberFormatCode";
/** Whether an argument was read from cells — a range, or one cell by reference (#166). */
@@ -673,7 +673,9 @@ export const FUNCTIONS: Record = {
const n = isBlank(v) ? 0 : toNumber(v);
if (isError(n)) return toDisplayString(v);
if (code === "") return "";
- return isDateFormatCode(code) ? formatSerial(n, code) : formatNumberCode(n, code);
+ if (!isDateFormatCode(code)) return formatNumberCode(n, code);
+ // A negative serial is no date at all, and Excel says so rather than inventing one (#219).
+ return showableAsDate(n) ? formatSerial(n, code) : ERR_VALUE;
},
// Both are serials (#45), in the reader's own time zone: NOW used to be UTC while TODAY was local,
// so in Bangkok between midnight and 7am the two disagreed about the date.