diff --git a/README.en.md b/README.en.md
index 1d7e952..7360683 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), 2014 automated tests.
+an undo that does not erase the other person's work. Bilingual UI (Thai/English), 2017 automated tests.
---
@@ -109,7 +109,7 @@ Want the harder parts: [embedding a Thai font in the PDF, with stacked tone mark
---
-### 🧪 What 2014 passing tests could not catch
+### 🧪 What 2017 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 — 2014 tests, `axe` clean on
+**And it happened again, in a different place.** With every gate green — 2017 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 2014-case Vitest suite |
+| `npm test` | Run the 2017-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) |
@@ -3045,7 +3045,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 (2014 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 (2017 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
@@ -3728,7 +3728,10 @@ seconds, not the time of day. 1.5 days in `[h]:mm` shows `36:00` (it used to sho
`[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!`.
+negative value under a date or time code gives `#VALUE!`. Opening an `.xlsx` does the same (#241): a cell of 0 or a
+negative number under a date or time format opens as that number with its format, formulas read it as a number, and
+export writes the number back. It used to turn into the text `1899-12-31` in silence, which `SUM` skipped and export
+wrote over the number in the file.
**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
@@ -4100,13 +4103,13 @@ the framework bundle itself, which isn't a trade worth making here. Written down
## 🧪 Testing
```bash
-npm test # 2014 cases across 145 files, via Vitest
+npm test # 2017 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 2014 cases opens the app**, and nearly every bug this project found by hand lived in
+**But not one of those 2017 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`
@@ -4225,10 +4228,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.)
-> **2014 tests passed, and 43% of the assistant's answers were unusable** — because those tests mock
+> **2017 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 2014 passing tests could not catch](#-what-2014-passing-tests-could-not-catch), repeatable
+> [What 2017 passing tests could not catch](#-what-2017-passing-tests-could-not-catch), repeatable
> with `npm run check:ai`.
| File | Cases | Tests |
@@ -4271,7 +4274,7 @@ code. Proving a gate means checking that the thing you meant to break actually b
| `sheetMerges.test.ts` | 35 | Which cells a merge swallows, shifting merges on row/column insert and delete, dropping one that collapses to a single cell |
| `sheetTemplate.test.ts` | 14 | Which cells are locked vs. fields, inline and range-backed dropdown options, column-width conversion |
| `fileLosses.test.ts` | 11 | What an opened file can't keep (#83): a real file with a picture, a chart and a function the engine lacks, counted with its sheet; that formula exported as it was; a plain file reporting nothing; `_xlfn.` spelling of a function the engine has not counted; PivotTables, shapes, macros, links and a chart sheet as the spec lays them out; a non-zip not failing the open; range names that cannot come in (#60) as one line of the same report, after the missing functions, the whole report for a file with nothing else, and still said when the package cannot be read again; constant error cells (#227) no longer reported, since the app reads them as the errors they are from #226 |
-| `excelIO.test.ts` | 58 | Builds a real .xlsx and round-trips it: reading fields/dropdowns/widths, an unprotected file isn't a template, export→import comes back identical, and styling (fills/font sizes/borders/row heights/merges) round-trips, as do all five kinds of conditional formatting rule and cell notes (both the plain-string and Excel's rich-text form), cross-sheet dropdowns read from the sheet the rule names (quoted names, a hidden sheet, a missing one, and the PaynEat ERP draft-0 template), a column exactly width 9 still 9 after export (#136), a merge in the file holding its value in the top-left cell only with SUM/COUNTA matching Excel and the merge still exported (#55), a real `t="e"` cell opening as its error code rather than blank, going back out as `t="e"` and still there on a reopen, while `'#N/A` and a Text-formatted cell go out as text (#227) |
+| `excelIO.test.ts` | 61 | Builds a real .xlsx and round-trips it: reading fields/dropdowns/widths, an unprotected file isn't a template, export→import comes back identical, and styling (fills/font sizes/borders/row heights/merges) round-trips, as do all five kinds of conditional formatting rule and cell notes (both the plain-string and Excel's rich-text form), cross-sheet dropdowns read from the sheet the rule names (quoted names, a hidden sheet, a missing one, and the PaynEat ERP draft-0 template), a column exactly width 9 still 9 after export (#136), a merge in the file holding its value in the top-left cell only with SUM/COUNTA matching Excel and the merge still exported (#55), a real `t="e"` cell opening as its error code rather than blank, going back out as `t="e"` and still there on a reopen, while `'#N/A` and a Text-formatted cell go out as text (#227); a cell of 0 or a negative number under a date or time format opening as the number, read by formulas as one, and exported back with its format (#241) |
| `charts.test.ts` | 42 | Reading a range into series and labels (including a text label column), gaps for non-numbers, a zero-anchored axis, moving/resizing/clamping a chart's frame, what the legend names per kind, shifting on edits |
| `server/urlGuard.test.ts` | 31 | Addresses the server refuses to reach (loopback, private ranges, cloud metadata, IPv6 link-local), IPv4 embedded in IPv6 (`::ffff:`, `::`, SIIT, NAT64, 6to4, Teredo), non-http schemes, and the allowlist |
| `server/sourcesAuth.test.ts` | 9 | No token means off, right/wrong/prefix tokens, and telling "switched off" apart from "wrong token" |
diff --git a/README.md b/README.md
index 566cfd6..5401405 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), 2014 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
+(Thai/English), 2017 automated tests. บันทึกบนคลาวด์และการแก้พร้อมกันหลายคนมีให้เลือกใช้ได้ โดยต่อ backend ของคุณเอง
---
@@ -106,7 +106,7 @@ top/bottom ranks, colour scales and data bars — and round-trips through Excel'
---
-### 🧪 สิ่งที่เทสต์ 2014 เคสจับไม่ได้
+### 🧪 สิ่งที่เทสต์ 2017 เคสจับไม่ได้
เทสต์ของผู้ช่วย 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 ที่มองเห็น เป็นตัวเลขผิดที่มองไม่เห็น**
-**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2014 เทสต์ผ่าน, `axe` ไม่มี violation ทั้งสองหน้า
+**แล้วมันก็เกิดอีก คนละที่** ตอนที่ทุกด่านเขียวหมด — 2017 เทสต์ผ่าน, `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 2014 เคสด้วย Vitest |
+| `npm test` | รัน unit test 2017 เคสด้วย Vitest |
| `npm run check:readme` | ตรวจว่า README ยังตรงกับโค้ด (ลิงก์/ภาพ/จำนวนเทสต์/โมดูลใหม่/สองภาษาตรงกัน) |
| `npm run check:screens` | ตัวเลขที่พิมพ์อยู่บนภาพหน้าจอยังตรงกับซอร์สไหม |
| `npm run check:rls` | ยิงจริงสองบัญชีบน Supabase ของคุณ ว่าฐานข้อมูลปฏิเสธตามที่ policy เขียนไว้ไหม (ต้องตั้ง env) |
@@ -2647,7 +2647,7 @@ stack ของ `font-mono` จึงต่อท้ายด้วย Plex Sans
| `@anthropic-ai/sdk` | เชื่อมต่อ Claude API สำหรับผู้ช่วย AI |
| `lucide-react` | ไอคอน UI |
| `clsx` | รวม className แบบมีเงื่อนไข |
-| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2014 เคส) |
+| `vitest` | unit test เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, แปลง JSON เป็นตาราง, การแบ่งหน้า/rate limit, แม่แบบ/รูปแบบจากไฟล์ และบล็อกข้อมูลสด (2017 เคส) |
> **หมายเหตุ:** ไม่ได้ใช้ไลบรารีคำนวณสูตรสำเร็จรูป (เช่น HyperFormula) แต่เขียน **เอนจินคำนวณสูตรขึ้นเอง**
> ทั้ง tokenizer, parser, evaluator และฟังก์ชันต่างๆ เพื่อควบคุมพฤติกรรมได้เต็มที่ ดูรายละเอียดที่หัวข้อ
@@ -3264,6 +3264,8 @@ error อยู่แล้ว คือ `IF` `IFERROR` `IFNA` `COUNT` `COUNTA`
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!`
+· เปิดไฟล์ .xlsx ก็เหมือนกัน (#241): ช่อง 0 หรือค่าติดลบในรูปแบบวันที่หรือเวลาเปิดมาเป็นตัวเลขพร้อมรูปแบบเดิม สูตรที่อ่านช่องนั้นได้ตัวเลข และส่งออกกลับเป็นตัวเลข
+(เดิมกลายเป็นข้อความ `1899-12-31` เงียบๆ `SUM` ข้ามช่องนั้น และส่งออกเขียนข้อความทับตัวเลขในไฟล์)
**ยังไม่รองรับ:** `MATCH` ต้องเป็นช่วงแถวเดียวหรือคอลัมน์เดียว ถ้าส่งช่วงสองมิติจะได้ `#N/A`
แทนการเดาตำแหน่งให้ · ยังไม่มี `MROUND` · การคำนวณที่ค่าล้น (`10^308*10`) ยังโชว์เป็น Infinity แทน `#NUM!`
@@ -3556,13 +3558,13 @@ B อ่านและแก้ได้ แต่ยังยึดเป็
## 🧪 การทดสอบ
```bash
-npm test # 2014 เคส ใน 145 ไฟล์ ด้วย Vitest
+npm test # 2017 เคส ใน 145 ไฟล์ ด้วย Vitest
```
โฟกัสเทสต์ไปที่ **เอนจินคำนวณสูตร, ตรรกะเรียงข้อมูล, การแปลง JSON เป็นตาราง, การไล่ดึงหน้าถัดไป, การถอยเมื่อโดน rate limit, แม่แบบจากไฟล์ Excel, กฎจัดรูปแบบตามเงื่อนไข และการวางบล็อกข้อมูลสด** — ส่วนที่เป็น pure function ล้วน ไม่ต้องพึ่ง React/DOM
จึงเทสต์ได้เร็วและมั่นใจได้สูง
-**แต่ 2014 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
+**แต่ 2017 เคสนั้นไม่มีสักเคสที่เปิดแอป** และบั๊กที่โปรเจกต์นี้เจอด้วยการนั่งกดเอง อยู่ที่รอยต่อระหว่างชิ้นส่วนที่ผ่านเทสต์
ทุกตัวแทบทั้งหมด — ปุ่ม "+ แถว" บนแถบเครื่องมือเรียก `addRow` ที่ไม่ประกาศอะไรเลย ขณะที่ `insertRowAtSelection`
ข้าง ๆ ประกาศถูกต้อง (ทั้งคู่เทสต์ผ่าน) · ผู้ช่วย AI ส่งช่วงที่รวมหัวคอลัมน์ไปด้วย เพราะตัวประกอบ context อ่าน
`sheet.cells` ที่เป็นสูตรดิบแทนค่าที่คำนวณแล้ว (ทั้งคู่เทสต์ผ่าน) · ปุ่มใหม่หนึ่งปุ่มดันปุ่มสลับภาษาตกขอบจอไป 42px
@@ -3662,9 +3664,9 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
ในตารางแล้วสองข้อในflow ที่สามตกทันที (ครั้งแรกที่ลองปิดยังเขียวอยู่ เพราะ `case` ที่แทรกเข้าไปไปอยู่หลัง
`case "ArrowRight"` เดิม กลายเป็นโค้ดตาย — การพิสูจน์ต้องเช็กด้วยว่าของที่ตั้งใจทำให้พังนั้นพังจริง)
-> **2014 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
+> **2017 เคสผ่านหมด แต่ 43% ของคำตอบจากผู้ช่วย AI ใช้ไม่ได้** — เพราะเทสต์ส่วนนั้น mock ตัวโมเดลไว้
> มันจึงตอบตามที่คนเขียนเทสต์คิดว่ามันจะตอบ จำนวนเทสต์บอกได้แค่ว่าเราถามอะไรไปบ้าง ไม่ได้บอกว่าเราถาม
-> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2014 เคสจับไม่ได้](#-สิ่งที่เทสต์-2014-เคสจับไม่ได้)
+> ครบหรือยัง เรื่องนี้เจอได้ก็ต่อเมื่อเอา API key จริงมายิง — ดู [สิ่งที่เทสต์ 2017 เคสจับไม่ได้](#-สิ่งที่เทสต์-2017-เคสจับไม่ได้)
> และรันซ้ำเองได้ด้วย `npm run check:ai`
| ไฟล์ | เคส | ทดสอบอะไร |
@@ -3707,7 +3709,7 @@ seed ถูกปักไว้ ด่านที่แดงจึงแด
| `sheetMerges.test.ts` | 35 | ช่องไหนถูก merge กลืน, การเลื่อน merge ตอนแทรก/ลบแถว-คอลัมน์, merge ที่เหลือช่องเดียวถูกทิ้ง |
| `sheetTemplate.test.ts` | 14 | เซลล์ไหนล็อก/กรอกได้, ตัวเลือก dropdown แบบ inline และแบบอ้างช่วง, แปลงความกว้างคอลัมน์ |
| `fileLosses.test.ts` | 11 | ไฟล์ที่เปิดมามีอะไรที่เก็บไว้ไม่ได้ (#83): ไฟล์จริงที่มีรูป กราฟ และฟังก์ชันที่เอนจินไม่มี นับได้ครบพร้อมชื่อชีต, สูตรนั้นส่งออกไปเหมือนเดิม, ไฟล์ธรรมดาไม่มีรายงาน, `_xlfn.` ของฟังก์ชันที่มีไม่ถูกนับ, Pivot Table/รูปร่าง/มาโคร/ลิงก์ และ chart sheet ตามรูปแบบใน spec, ไฟล์ที่ไม่ใช่ zip ไม่ทำให้การเปิดล้ม, ชื่อช่วงที่นำเข้าไม่ได้ (#60) เป็นบรรทัดหนึ่งของรายงานเดียวกัน ต่อจากฟังก์ชันที่ไม่มี เป็นรายงานทั้งหมดของไฟล์ที่ไม่มีอย่างอื่น และยังบอกแม้อ่าน package ซ้ำไม่ได้, เซลล์ error คงที่ (#227) ไม่อยู่ในรายงานแล้วเพราะแอปอ่านเป็น error จริงตั้งแต่ #226 |
-| `excelIO.test.ts` | 58 | สร้างไฟล์ .xlsx จริงแล้ววนครบรอบ: อ่านช่องกรอก/dropdown/ความกว้าง, ไฟล์ที่ไม่ protect ไม่ใช่แม่แบบ, export แล้ว import กลับได้เหมือนเดิม, และรูปแบบ (แถบสี/ขนาดตัวอักษร/เส้นขอบ/ความสูงแถว/merge) ครบรอบ รวมถึงกฎจัดรูปแบบตามเงื่อนไขทั้ง 5 แบบ และโน้ตของเซลล์ (ทั้งที่เขียนเป็นข้อความธรรมดาและ rich text ของ Excel), dropdown ข้ามชีตอ่านจากชีตที่กฎชี้ (ชื่อชีตมีเครื่องหมายคำพูด, ชีตซ่อน, ชีตที่ไม่มีอยู่, แม่แบบร่าง 0 ของ PaynEat ERP), คอลัมน์กว้าง 9 พอดีส่งออกแล้วยังเป็น 9 (#136), merge ในไฟล์มีค่าที่ซ้ายบนช่องเดียว SUM/COUNTA ตรงกับ Excel และ merge ยังส่งออกไป (#55), เซลล์ `t="e"` จริงเปิดมาได้รหัส error ไม่ใช่ช่องว่าง ส่งออกกลับเป็น `t="e"` และเปิดซ้ำยังอยู่ ส่วน `'#N/A` กับช่องรูปแบบข้อความยังออกเป็นข้อความ (#227) |
+| `excelIO.test.ts` | 61 | สร้างไฟล์ .xlsx จริงแล้ววนครบรอบ: อ่านช่องกรอก/dropdown/ความกว้าง, ไฟล์ที่ไม่ protect ไม่ใช่แม่แบบ, export แล้ว import กลับได้เหมือนเดิม, และรูปแบบ (แถบสี/ขนาดตัวอักษร/เส้นขอบ/ความสูงแถว/merge) ครบรอบ รวมถึงกฎจัดรูปแบบตามเงื่อนไขทั้ง 5 แบบ และโน้ตของเซลล์ (ทั้งที่เขียนเป็นข้อความธรรมดาและ rich text ของ Excel), dropdown ข้ามชีตอ่านจากชีตที่กฎชี้ (ชื่อชีตมีเครื่องหมายคำพูด, ชีตซ่อน, ชีตที่ไม่มีอยู่, แม่แบบร่าง 0 ของ PaynEat ERP), คอลัมน์กว้าง 9 พอดีส่งออกแล้วยังเป็น 9 (#136), merge ในไฟล์มีค่าที่ซ้ายบนช่องเดียว SUM/COUNTA ตรงกับ Excel และ merge ยังส่งออกไป (#55), เซลล์ `t="e"` จริงเปิดมาได้รหัส error ไม่ใช่ช่องว่าง ส่งออกกลับเป็น `t="e"` และเปิดซ้ำยังอยู่ ส่วน `'#N/A` กับช่องรูปแบบข้อความยังออกเป็นข้อความ (#227), ช่อง 0 และค่าติดลบในรูปแบบวันที่/เวลาเปิดมาเป็นตัวเลข สูตรอ่านได้ และส่งออกกลับเป็นตัวเลขพร้อมรูปแบบ (#241) |
| `charts.test.ts` | 42 | อ่านช่วงเป็นชุดข้อมูล+ป้ายกำกับ (รวมกรณีคอลัมน์ซ้ายเป็นข้อความ), ช่องว่างเมื่อไม่ใช่ตัวเลข, แกนที่รวมศูนย์, การย้าย/ย่อขยาย/หนีบกรอบไว้ในชีต, คำอธิบายสีตามชนิดกราฟ, การเลื่อนตามแทรก/ลบ |
| `server/urlGuard.test.ts` | 31 | ที่อยู่ที่ห้ามยิงถึง (loopback, วงใน, metadata ของคลาวด์, IPv6 link-local), IPv4 ที่ซ่อนใน IPv6 (`::ffff:`, `::`, SIIT, NAT64, 6to4, Teredo), scheme ที่ไม่ใช่ http, และ allowlist |
| `server/sourcesAuth.test.ts` | 9 | ไม่ตั้งโทเคน = ปิดทั้งหมด, โทเคนถูก/ผิด/เป็นแค่บางส่วน, แยก "ปิดอยู่" ออกจาก "โทเคนผิด" |
diff --git a/public/screenshots/27-landing-stats.png b/public/screenshots/27-landing-stats.png
index 68eac2d..079b0ab 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 343aeeb..b02bc92 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 af4988d..d337690 100644
--- a/public/screenshots/screenshots.json
+++ b/public/screenshots/screenshots.json
@@ -5,7 +5,7 @@
"27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
- "tests": 2014,
+ "tests": 2017,
"securityTests": 325
},
"38-landing-problems.png": {
@@ -21,7 +21,7 @@
"en/27-landing-stats.png": {
"paletteFormulas": 37,
"engineFunctions": 66,
- "tests": 2014,
+ "tests": 2017,
"securityTests": 325
},
"en/38-landing-problems.png": {
diff --git a/public/social-preview.png b/public/social-preview.png
index 956e80b..49fcff1 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 bf61053..da9e52a 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"],
- ["2014", "automated tests"],
+ ["2017", "automated tests"],
["325", "of them security"],
["0", "formula libraries"],
];
diff --git a/src/i18n/en.ts b/src/i18n/en.ts
index 4e113d6..89a45f1 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: "2014", label: "automated tests" },
+ { value: "2017", 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 c17e278..a0f6956 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: "2014", label: "เทสต์อัตโนมัติ" },
+ { value: "2017", label: "เทสต์อัตโนมัติ" },
{ value: "325", label: "เทสต์ด้านความปลอดภัย" },
{ value: "0", label: "ไลบรารีคำนวณสูตร" },
],
diff --git a/src/lib/excelIO.test.ts b/src/lib/excelIO.test.ts
index be84f61..0a3f380 100644
--- a/src/lib/excelIO.test.ts
+++ b/src/lib/excelIO.test.ts
@@ -5,6 +5,7 @@ import ExcelJS from "exceljs";
import JSZip from "jszip";
import { describe, expect, it } from "vitest";
import { exportWorkbookToXlsxBlob, importWorkbookFromFile } from "./excelIO";
+import { formatNumberForDisplay } from "./cellFormat";
import { computeSheet, createEmptySheet, createWorkbookResolver, setCellRaw, SheetModel } from "./sheet";
import { evaluateConditionalFormats } from "./conditionalFormat";
import { cellKey } from "./sheetTemplate";
@@ -766,3 +767,68 @@ describe("a constant error cell in an opened file (#227)", () => {
expect(ws.getCell("A4").value).toEqual({ error: "#N/A" });
});
});
+
+/**
+ * Zero and negative numbers under a date or time format, as a timesheet holds them (#241).
+ * ExcelJS hands such a cell over as a `Date`, and the importer wrote it as year-1899 ISO text that
+ * no date reading accepts: the cell became text, SUM skipped it and export wrote the text back.
+ */
+async function negativeTimesFile(): Promise {
+ const wb = new ExcelJS.Workbook();
+ const ws = wb.addWorksheet("Hours");
+ const put = (address: string, value: number, numFmt: string) => {
+ ws.getCell(address).value = value;
+ ws.getCell(address).numFmt = numFmt;
+ };
+ put("A1", 0, "dd/mm/yyyy");
+ put("A2", -1, "dd/mm/yyyy");
+ put("A3", -1.5, "[h]:mm");
+ put("A4", 1.5, "[h]:mm");
+ put("A5", 45366, "dd/mm/yyyy");
+ ws.getCell("A8").value = { formula: "SUM(A2:A4)", result: -1 } as ExcelJS.CellFormulaValue;
+ ws.getCell("A8").numFmt = "[h]:mm";
+ ws.getCell("B2").value = { formula: "A2+2", result: 1 } as ExcelJS.CellFormulaValue;
+ return new File([await wb.xlsx.writeBuffer()], "hours.xlsx");
+}
+
+describe("zero and negative date or time cells in an opened file (#241)", () => {
+ const shown = (sheet: SheetModel, r: number, c: number) => {
+ const value = computeSheet(sheet).values[r][c];
+ const format = sheet.formats[r][c];
+ return typeof value === "number" && format?.numberFormat
+ ? formatNumberForDisplay(value, format.numberFormat, format.dateFormat, format.numFmtCode)
+ : String(value);
+ };
+
+ it("open as the numbers they are, shown as Excel shows them", async () => {
+ const [{ sheet }] = await importWorkbookFromFile(await negativeTimesFile());
+ expect([0, 1, 2].map((r) => sheet.cells[r][0])).toEqual(["0", "-1", "-1.5"]);
+ expect([0, 1, 2, 3, 4].map((r) => computeSheet(sheet).values[r][0])).toEqual([0, -1, -1.5, 1.5, 45366]);
+ expect(shown(sheet, 0, 0)).toBe("00/01/1900");
+ expect(shown(sheet, 1, 0)).toBe("########");
+ expect(shown(sheet, 2, 0)).toBe("########");
+ // Positive dates and times open as they always did.
+ expect(shown(sheet, 3, 0)).toBe("36:00");
+ expect(shown(sheet, 4, 0)).toBe("15/03/2024");
+ });
+
+ it("and formulas read them as numbers: A2+2 is 1, the total of A2:A4 is -1", async () => {
+ const [{ sheet }] = await importWorkbookFromFile(await negativeTimesFile());
+ const { values } = computeSheet(sheet);
+ expect(values[1][1]).toBe(1);
+ expect(values[7][0]).toBe(-1);
+ });
+
+ it("and go back out as the same numbers with their formats", async () => {
+ const [{ sheet }] = await importWorkbookFromFile(await negativeTimesFile());
+ const ws = await readBack(await exportWorkbookToXlsxBlob(exportable(sheet, "Hours")));
+ const out = (address: string) => {
+ const value = ws.getCell(address).value;
+ // ExcelJS reads a date-formatted number back as a Date; turn it into its serial.
+ return value instanceof Date ? Math.round(((value.getTime() - Date.UTC(1899, 11, 30)) / 86_400_000) * 86_400) / 86_400 : value;
+ };
+ expect(["A1", "A2", "A3", "A4", "A5"].map(out)).toEqual([0, -1, -1.5, 1.5, 45366]);
+ expect(["A1", "A2", "A3"].map((a) => ws.getCell(a).numFmt)).toEqual(["dd/mm/yyyy", "dd/mm/yyyy", "[h]:mm"]);
+ expect(await cellXml(await exportWorkbookToXlsxBlob(exportable(sheet, "Hours")), "A2")).not.toContain('t="s"');
+ });
+});
diff --git a/src/lib/excelIO.ts b/src/lib/excelIO.ts
index aab5e09..d5b6e4d 100644
--- a/src/lib/excelIO.ts
+++ b/src/lib/excelIO.ts
@@ -135,6 +135,10 @@ function rawForDate(d: Date, numFmt: string | undefined): string {
const whole = Math.floor(serial);
// Midnight in a time-only format is `00:00`, not a date: serial 0 has no day to show.
const timeOnly = whole === 0 && (serial !== 0 || (!!numFmt && kindOfDateCode(numFmt) === "time"));
+ // Zero and below have no date to write: ISO text for them is a year-1899 day no date reading
+ // accepts, so the cell turned into text (#241). Kept as the number, its format shows it as Excel
+ // does (`00/01/1900`, `########`).
+ if (serial < 0 || (serial === 0 && !timeOnly)) return String(serial);
const kind = timeOnly ? "time" : serial === whole ? "date" : "datetime";
return isoFromSerial(serial, kind);
}