feat : excel in website
CI / Verify (push) Successful in 1m15s
CI / Build immutable images and deploy (push) Successful in 4m53s

This commit is contained in:
2026-09-05 15:04:14 +07:00 Unverified
parent c19cbe6537
commit e66b5a3a0d
18 changed files with 4386 additions and 83 deletions
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from "vitest";
import {
addExtraSheetColumn,
addExtraSheetRow,
createExtraSheet,
deleteExtraSheetColumn,
deleteExtraSheetRow,
extraSheetSchema,
extraSheetToMatrix,
matrixToExtraSheet,
moveExtraSheetColumn,
moveExtraSheetRow,
} from "@/lib/guides/extra-sheet";
function idFactory() {
let index = 0;
return (prefix: string) => `${prefix}-${++index}`;
}
describe("Extra sheet", () => {
it("creates a valid two-by-two sheet", () => {
const sheet = createExtraSheet(idFactory());
expect(extraSheetSchema.safeParse(sheet).success).toBe(true);
expect(sheet.columns.map((column) => column.label)).toEqual(["คอลัมน์ 1", "คอลัมน์ 2"]);
expect(extraSheetToMatrix(sheet)).toEqual([
[{ value: "" }, { value: "" }],
[{ value: "" }, { value: "" }],
]);
});
it("preserves cell identity while rows and columns move", () => {
const createId = idFactory();
const sheet = createExtraSheet(createId);
const firstColumnId = sheet.columns[0].id;
const secondColumnId = sheet.columns[1].id;
sheet.rows[0].cells[firstColumnId] = "A";
sheet.rows[0].cells[secondColumnId] = "B";
sheet.rows[1].cells[firstColumnId] = "C";
const moved = moveExtraSheetRow(moveExtraSheetColumn(sheet, 0, 1), 0, 1);
expect(extraSheetToMatrix(moved)).toEqual([
[{ value: "" }, { value: "C" }],
[{ value: "B" }, { value: "A" }],
]);
});
it("adds and removes rows and columns without orphaned cells", () => {
const createId = idFactory();
const initial = createExtraSheet(createId);
const columnId = createId("column");
const expanded = addExtraSheetRow(
addExtraSheetColumn(initial, "Notes", columnId),
createId("row"),
);
expect(expanded.rows).toHaveLength(3);
expect(expanded.rows.every((row) => row.cells[columnId] === "")).toBe(true);
const reduced = deleteExtraSheetColumn(expanded, 2);
expect(reduced.rows.every((row) => !(columnId in row.cells))).toBe(true);
expect(deleteExtraSheetRow(reduced, 2).rows).toHaveLength(2);
});
it("expands rows and columns when a pasted matrix is larger", () => {
const createId = idFactory();
const initial = createExtraSheet(createId);
const expanded = matrixToExtraSheet(initial, [
[{ value: "1" }, { value: "2" }, { value: "3" }],
[{ value: "4" }, { value: "5" }, { value: "6" }],
[{ value: "7" }, { value: "8" }, { value: "=1+1" }],
], createId);
expect(expanded.columns).toHaveLength(3);
expect(expanded.rows).toHaveLength(3);
expect(extraSheetToMatrix(expanded)[2][2]?.value).toBe("=1+1");
expect(extraSheetSchema.safeParse(expanded).success).toBe(true);
});
it("rejects duplicate labels and cells for missing columns", () => {
const sheet = createExtraSheet(idFactory());
sheet.columns[1].label = sheet.columns[0].label.toUpperCase();
sheet.rows[0].cells.missing = "orphan";
const result = extraSheetSchema.safeParse(sheet);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.message)).toContain("ชื่อคอลัมน์ต้องไม่ซ้ำกัน");
expect(result.error.issues.map((issue) => issue.message)).toContain("ไม่พบคอลัมน์ของเซลล์นี้");
}
});
});
+160
View File
@@ -0,0 +1,160 @@
import * as z from "zod";
const extraSheetId = z.string().min(1).max(128);
export const extraSheetSchema = z.strictObject({
columns: z.array(z.strictObject({
id: extraSheetId,
label: z.string().trim().min(1).max(120),
})).min(1),
rows: z.array(z.strictObject({
id: extraSheetId,
cells: z.record(z.string(), z.string().max(10_000)),
})).min(1),
}).superRefine((sheet, context) => {
const columnIds = new Set(sheet.columns.map((column) => column.id));
if (columnIds.size !== sheet.columns.length) {
context.addIssue({ code: "custom", path: ["columns"], message: "รหัสคอลัมน์ต้องไม่ซ้ำกัน" });
}
const columnLabels = new Set(sheet.columns.map((column) => column.label.toLocaleLowerCase()));
if (columnLabels.size !== sheet.columns.length) {
context.addIssue({ code: "custom", path: ["columns"], message: "ชื่อคอลัมน์ต้องไม่ซ้ำกัน" });
}
const rowIds = new Set(sheet.rows.map((row) => row.id));
if (rowIds.size !== sheet.rows.length) {
context.addIssue({ code: "custom", path: ["rows"], message: "รหัสแถวต้องไม่ซ้ำกัน" });
}
sheet.rows.forEach((row, rowIndex) => {
Object.keys(row.cells).forEach((columnId) => {
if (!columnIds.has(columnId)) {
context.addIssue({
code: "custom",
path: ["rows", rowIndex, "cells", columnId],
message: "ไม่พบคอลัมน์ของเซลล์นี้",
});
}
});
});
});
export type ExtraSheet = z.output<typeof extraSheetSchema>;
export type ExtraSheetCell = { value: string };
export type ExtraSheetMatrix = Array<Array<ExtraSheetCell | undefined>>;
export function nextExtraSheetColumnLabel(columns: ExtraSheet["columns"]): string {
const labels = new Set(columns.map((column) => column.label.toLocaleLowerCase()));
let index = columns.length + 1;
while (labels.has(`คอลัมน์ ${index}`.toLocaleLowerCase())) index += 1;
return `คอลัมน์ ${index}`;
}
export function createExtraSheet(createId: (prefix: string) => string): ExtraSheet {
const columns = [1, 2].map((index) => ({
id: createId("extra-sheet-column"),
label: `คอลัมน์ ${index}`,
}));
return {
columns,
rows: [1, 2].map(() => ({
id: createId("extra-sheet-row"),
cells: Object.fromEntries(columns.map((column) => [column.id, ""])),
})),
};
}
export function extraSheetToMatrix(sheet: ExtraSheet): ExtraSheetMatrix {
return sheet.rows.map((row) =>
sheet.columns.map((column) => ({ value: row.cells[column.id] ?? "" })),
);
}
export function matrixToExtraSheet(
sheet: ExtraSheet,
matrix: ExtraSheetMatrix,
createId: (prefix: string) => string,
): ExtraSheet {
const columnCount = Math.max(
sheet.columns.length,
...matrix.map((row) => row.length),
);
const columns = [...sheet.columns];
while (columns.length < columnCount) {
columns.push({
id: createId("extra-sheet-column"),
label: nextExtraSheetColumnLabel(columns),
});
}
const rowCount = Math.max(sheet.rows.length, matrix.length);
const rows = Array.from({ length: rowCount }, (_, rowIndex) => ({
id: sheet.rows[rowIndex]?.id ?? createId("extra-sheet-row"),
cells: Object.fromEntries(columns.map((column, columnIndex) => [
column.id,
String(matrix[rowIndex]?.[columnIndex]?.value ?? ""),
])),
}));
return { columns, rows };
}
export function addExtraSheetColumn(
sheet: ExtraSheet,
label: string,
id: string,
): ExtraSheet {
return {
columns: [...sheet.columns, { id, label }],
rows: sheet.rows.map((row) => ({ ...row, cells: { ...row.cells, [id]: "" } })),
};
}
export function addExtraSheetRow(sheet: ExtraSheet, id: string): ExtraSheet {
return {
...sheet,
rows: [...sheet.rows, {
id,
cells: Object.fromEntries(sheet.columns.map((column) => [column.id, ""])),
}],
};
}
export function moveExtraSheetColumn(
sheet: ExtraSheet,
from: number,
to: number,
): ExtraSheet {
if (to < 0 || to >= sheet.columns.length || from === to) return sheet;
const columns = [...sheet.columns];
const [column] = columns.splice(from, 1);
columns.splice(to, 0, column);
return { ...sheet, columns };
}
export function moveExtraSheetRow(
sheet: ExtraSheet,
from: number,
to: number,
): ExtraSheet {
if (to < 0 || to >= sheet.rows.length || from === to) return sheet;
const rows = [...sheet.rows];
const [row] = rows.splice(from, 1);
rows.splice(to, 0, row);
return { ...sheet, rows };
}
export function deleteExtraSheetColumn(sheet: ExtraSheet, index: number): ExtraSheet {
if (sheet.columns.length === 1) return sheet;
const columnId = sheet.columns[index]?.id;
if (!columnId) return sheet;
return {
columns: sheet.columns.filter((_, columnIndex) => columnIndex !== index),
rows: sheet.rows.map((row) => ({
...row,
cells: Object.fromEntries(Object.entries(row.cells).filter(([key]) => key !== columnId)),
})),
};
}
export function deleteExtraSheetRow(sheet: ExtraSheet, index: number): ExtraSheet {
if (sheet.rows.length === 1) return sheet;
return { ...sheet, rows: sheet.rows.filter((_, rowIndex) => rowIndex !== index) };
}
+32
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { calculateTeamMetrics } from "@/lib/guides/calculations";
import { createExtraSheet } from "@/lib/guides/extra-sheet";
import { artifactSectionSchema, constellationSectionSchema, createGuideSchema, extraSectionSchema, teamSectionSchema, updateGuideSchema, weaponSectionSchema } from "@/lib/guides/schemas";
import {
recommendedStatElement,
@@ -207,6 +208,37 @@ describe("structured guide validation", () => {
sections: [{ title: "Extra", rows: [{ ...row, catalogItems: [row.catalogItems[0], row.catalogItems[0]] }] }],
}).success).toBe(false);
});
it("validates an optional Extra card sheet", () => {
let id = 0;
const sheet = createExtraSheet((prefix) => `${prefix}-${++id}`);
sheet.rows[0].cells[sheet.columns[0].id] = "=1+1";
const row = {
mediaId: null,
heading: "Stats",
body: "",
percent: null,
backgroundColor: null,
highlights: [],
catalogItems: [],
sheet,
};
const result = extraSectionSchema.safeParse({
expectedVersion: 1,
sections: [{ title: "Extra", rows: [row] }],
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.sections[0].rows[0].sheet?.rows[0].cells[sheet.columns[0].id]).toBe("=1+1");
}
const invalidSheet = structuredClone(sheet);
invalidSheet.rows[0].cells.missing = "orphan";
expect(extraSectionSchema.safeParse({
expectedVersion: 1,
sections: [{ title: "Extra", rows: [{ ...row, sheet: invalidSheet }] }],
}).success).toBe(false);
});
});
describe("Team Calc", () => {
+2
View File
@@ -2,6 +2,7 @@ import * as z from "zod";
import { targetStatOptions } from "@/lib/guides/stats";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
import { extraSheetSchema } from "@/lib/guides/extra-sheet";
import { normalizeExtraRichText } from "@/lib/rich-text";
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
@@ -154,6 +155,7 @@ const extraRowSchema = z.strictObject({
(highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
).default([]),
sheet: extraSheetSchema.nullable().default(null),
}).transform((row) => ({
...row,
body: normalizeExtraRichText(row.body, row.highlights),