···405405 { name: 'reference', desc: 'The cell reference to get the column number from', required: true },
406406 ],
407407 },
408408+409409+ // --- Information ---
410410+ ISNUMBER: {
411411+ desc: 'Returns TRUE if the value is a number',
412412+ params: [
413413+ { name: 'value', desc: 'The value to check', required: true },
414414+ ],
415415+ },
416416+ ISTEXT: {
417417+ desc: 'Returns TRUE if the value is text',
418418+ params: [
419419+ { name: 'value', desc: 'The value to check', required: true },
420420+ ],
421421+ },
422422+ ISBLANK: {
423423+ desc: 'Returns TRUE if the value is blank',
424424+ params: [
425425+ { name: 'value', desc: 'The value to check', required: true },
426426+ ],
427427+ },
428428+ ISERROR: {
429429+ desc: 'Returns TRUE if the value is any error value',
430430+ params: [
431431+ { name: 'value', desc: 'The value to check for an error', required: true },
432432+ ],
433433+ },
434434+ ISNA: {
435435+ desc: 'Returns TRUE if the value is the #N/A error',
436436+ params: [
437437+ { name: 'value', desc: 'The value to check', required: true },
438438+ ],
439439+ },
440440+ ISLOGICAL: {
441441+ desc: 'Returns TRUE if the value is a logical value (TRUE or FALSE)',
442442+ params: [
443443+ { name: 'value', desc: 'The value to check', required: true },
444444+ ],
445445+ },
446446+ TYPE: {
447447+ desc: 'Returns the type of a value (1=number, 2=text, 4=logical, 16=error, 64=array)',
448448+ params: [
449449+ { name: 'value', desc: 'The value whose type to determine', required: true },
450450+ ],
451451+ },
452452+ N: {
453453+ desc: 'Converts a value to a number',
454454+ params: [
455455+ { name: 'value', desc: 'The value to convert (TRUE=1, FALSE=0, text=0)', required: true },
456456+ ],
457457+ },
458458+ T: {
459459+ desc: 'Returns the value if it is text, otherwise returns empty string',
460460+ params: [
461461+ { name: 'value', desc: 'The value to test and return if text', required: true },
462462+ ],
463463+ },
464464+465465+ // --- Math (additional) ---
466466+ SUMPRODUCT: {
467467+ desc: 'Returns the sum of the products of corresponding array elements',
468468+ params: [
469469+ { name: 'array1', desc: 'First array of values to multiply', required: true },
470470+ { name: 'array2', desc: 'Additional arrays to multiply element-wise', required: false },
471471+ ],
472472+ },
473473+ PRODUCT: {
474474+ desc: 'Multiplies all the numbers given as arguments',
475475+ params: [
476476+ { name: 'number1', desc: 'First number to multiply', required: true },
477477+ { name: 'number2', desc: 'Additional numbers to multiply', required: false },
478478+ ],
479479+ },
480480+ SIGN: {
481481+ desc: 'Returns the sign of a number: 1 if positive, -1 if negative, 0 if zero',
482482+ params: [
483483+ { name: 'number', desc: 'The number to get the sign of', required: true },
484484+ ],
485485+ },
486486+ EVEN: {
487487+ desc: 'Rounds a number up to the nearest even integer',
488488+ params: [
489489+ { name: 'number', desc: 'The number to round up', required: true },
490490+ ],
491491+ },
492492+ ODD: {
493493+ desc: 'Rounds a number up to the nearest odd integer',
494494+ params: [
495495+ { name: 'number', desc: 'The number to round up', required: true },
496496+ ],
497497+ },
498498+ CEILING: {
499499+ desc: 'Rounds a number up to the nearest multiple of significance',
500500+ params: [
501501+ { name: 'number', desc: 'The number to round', required: true },
502502+ { name: 'significance', desc: 'The multiple to round up to', required: true },
503503+ ],
504504+ },
505505+ FLOOR: {
506506+ desc: 'Rounds a number down to the nearest multiple of significance',
507507+ params: [
508508+ { name: 'number', desc: 'The number to round', required: true },
509509+ { name: 'significance', desc: 'The multiple to round down to', required: true },
510510+ ],
511511+ },
512512+ FACT: {
513513+ desc: 'Returns the factorial of a number',
514514+ params: [
515515+ { name: 'number', desc: 'The non-negative integer to compute the factorial of', required: true },
516516+ ],
517517+ },
518518+ COMBIN: {
519519+ desc: 'Returns the number of combinations for a given number of items',
520520+ params: [
521521+ { name: 'n', desc: 'The total number of items', required: true },
522522+ { name: 'k', desc: 'The number of items to choose', required: true },
523523+ ],
524524+ },
525525+ GCD: {
526526+ desc: 'Returns the greatest common divisor of two numbers',
527527+ params: [
528528+ { name: 'a', desc: 'First integer', required: true },
529529+ { name: 'b', desc: 'Second integer', required: true },
530530+ ],
531531+ },
532532+ LCM: {
533533+ desc: 'Returns the least common multiple of two numbers',
534534+ params: [
535535+ { name: 'a', desc: 'First integer', required: true },
536536+ { name: 'b', desc: 'Second integer', required: true },
537537+ ],
538538+ },
539539+ QUOTIENT: {
540540+ desc: 'Returns the integer portion of a division',
541541+ params: [
542542+ { name: 'numerator', desc: 'The dividend', required: true },
543543+ { name: 'denominator', desc: 'The divisor', required: true },
544544+ ],
545545+ },
546546+547547+ // --- Trigonometric ---
548548+ SIN: {
549549+ desc: 'Returns the sine of an angle (in radians)',
550550+ params: [
551551+ { name: 'angle', desc: 'The angle in radians', required: true },
552552+ ],
553553+ },
554554+ COS: {
555555+ desc: 'Returns the cosine of an angle (in radians)',
556556+ params: [
557557+ { name: 'angle', desc: 'The angle in radians', required: true },
558558+ ],
559559+ },
560560+ TAN: {
561561+ desc: 'Returns the tangent of an angle (in radians)',
562562+ params: [
563563+ { name: 'angle', desc: 'The angle in radians', required: true },
564564+ ],
565565+ },
566566+ ASIN: {
567567+ desc: 'Returns the arcsine (inverse sine) of a number',
568568+ params: [
569569+ { name: 'value', desc: 'A value between -1 and 1', required: true },
570570+ ],
571571+ },
572572+ ACOS: {
573573+ desc: 'Returns the arccosine (inverse cosine) of a number',
574574+ params: [
575575+ { name: 'value', desc: 'A value between -1 and 1', required: true },
576576+ ],
577577+ },
578578+ ATAN: {
579579+ desc: 'Returns the arctangent (inverse tangent) of a number',
580580+ params: [
581581+ { name: 'value', desc: 'The number to get the arctangent of', required: true },
582582+ ],
583583+ },
584584+ ATAN2: {
585585+ desc: 'Returns the arctangent from x and y coordinates',
586586+ params: [
587587+ { name: 'x_num', desc: 'The x-coordinate', required: true },
588588+ { name: 'y_num', desc: 'The y-coordinate', required: true },
589589+ ],
590590+ },
591591+ DEGREES: {
592592+ desc: 'Converts radians to degrees',
593593+ params: [
594594+ { name: 'radians', desc: 'The angle in radians to convert', required: true },
595595+ ],
596596+ },
597597+ RADIANS: {
598598+ desc: 'Converts degrees to radians',
599599+ params: [
600600+ { name: 'degrees', desc: 'The angle in degrees to convert', required: true },
601601+ ],
602602+ },
603603+604604+ // --- Text (additional) ---
605605+ PROPER: {
606606+ desc: 'Capitalizes the first letter of each word in a text string',
607607+ params: [
608608+ { name: 'text', desc: 'The text to capitalize', required: true },
609609+ ],
610610+ },
611611+ REPT: {
612612+ desc: 'Repeats text a given number of times',
613613+ params: [
614614+ { name: 'text', desc: 'The text to repeat', required: true },
615615+ { name: 'number_times', desc: 'Number of times to repeat', required: true },
616616+ ],
617617+ },
618618+ EXACT: {
619619+ desc: 'Checks whether two text strings are exactly the same (case-sensitive)',
620620+ params: [
621621+ { name: 'text1', desc: 'First text string', required: true },
622622+ { name: 'text2', desc: 'Second text string', required: true },
623623+ ],
624624+ },
625625+ REPLACE: {
626626+ desc: 'Replaces part of a text string with a different text string by position',
627627+ params: [
628628+ { name: 'old_text', desc: 'The original text', required: true },
629629+ { name: 'start_num', desc: 'Position of first character to replace (1-based)', required: true },
630630+ { name: 'num_chars', desc: 'Number of characters to replace', required: true },
631631+ { name: 'new_text', desc: 'The replacement text', required: true },
632632+ ],
633633+ },
634634+ CLEAN: {
635635+ desc: 'Removes all non-printable characters from text',
636636+ params: [
637637+ { name: 'text', desc: 'The text to clean', required: true },
638638+ ],
639639+ },
640640+ CHAR: {
641641+ desc: 'Returns the character specified by a number (character code)',
642642+ params: [
643643+ { name: 'number', desc: 'The character code (1-255)', required: true },
644644+ ],
645645+ },
646646+ CODE: {
647647+ desc: 'Returns the numeric code for the first character in a text string',
648648+ params: [
649649+ { name: 'text', desc: 'The text to get the code from', required: true },
650650+ ],
651651+ },
652652+653653+ // --- Date/Time (additional) ---
654654+ HOUR: {
655655+ desc: 'Returns the hour of a time value (0-23)',
656656+ params: [
657657+ { name: 'serial_number', desc: 'The time to extract the hour from', required: true },
658658+ ],
659659+ },
660660+ MINUTE: {
661661+ desc: 'Returns the minutes of a time value (0-59)',
662662+ params: [
663663+ { name: 'serial_number', desc: 'The time to extract minutes from', required: true },
664664+ ],
665665+ },
666666+ SECOND: {
667667+ desc: 'Returns the seconds of a time value (0-59)',
668668+ params: [
669669+ { name: 'serial_number', desc: 'The time to extract seconds from', required: true },
670670+ ],
671671+ },
672672+ WEEKDAY: {
673673+ desc: 'Returns the day of the week for a date',
674674+ params: [
675675+ { name: 'serial_number', desc: 'The date to find the day of week for', required: true },
676676+ { name: 'return_type', desc: '1=Sun-Sat (1-7), 2=Mon-Sun (1-7), 3=Mon-Sun (0-6)', required: false },
677677+ ],
678678+ },
679679+ EDATE: {
680680+ desc: 'Returns the date that is a given number of months before or after a start date',
681681+ params: [
682682+ { name: 'start_date', desc: 'The starting date', required: true },
683683+ { name: 'months', desc: 'Number of months to add (negative for before)', required: true },
684684+ ],
685685+ },
686686+ EOMONTH: {
687687+ desc: 'Returns the last day of the month a given number of months before or after a start date',
688688+ params: [
689689+ { name: 'start_date', desc: 'The starting date', required: true },
690690+ { name: 'months', desc: 'Number of months to offset', required: true },
691691+ ],
692692+ },
693693+ DAYS: {
694694+ desc: 'Returns the number of days between two dates',
695695+ params: [
696696+ { name: 'end_date', desc: 'The end date', required: true },
697697+ { name: 'start_date', desc: 'The start date', required: true },
698698+ ],
699699+ },
700700+ NETWORKDAYS: {
701701+ desc: 'Returns the number of working days between two dates (excluding weekends)',
702702+ params: [
703703+ { name: 'start_date', desc: 'The start date', required: true },
704704+ { name: 'end_date', desc: 'The end date', required: true },
705705+ ],
706706+ },
707707+708708+ // --- Statistical (additional) ---
709709+ LARGE: {
710710+ desc: 'Returns the k-th largest value in a data set',
711711+ params: [
712712+ { name: 'array', desc: 'The range of data', required: true },
713713+ { name: 'k', desc: 'The position (from the largest) to return', required: true },
714714+ ],
715715+ },
716716+ SMALL: {
717717+ desc: 'Returns the k-th smallest value in a data set',
718718+ params: [
719719+ { name: 'array', desc: 'The range of data', required: true },
720720+ { name: 'k', desc: 'The position (from the smallest) to return', required: true },
721721+ ],
722722+ },
723723+ RANK: {
724724+ desc: 'Returns the rank of a number in a list of numbers',
725725+ params: [
726726+ { name: 'number', desc: 'The number to rank', required: true },
727727+ { name: 'ref', desc: 'The list of numbers to rank against', required: true },
728728+ { name: 'order', desc: '0 or omitted for descending, non-zero for ascending', required: false },
729729+ ],
730730+ },
731731+ PERCENTILE: {
732732+ desc: 'Returns the k-th percentile of values in a range',
733733+ params: [
734734+ { name: 'array', desc: 'The range of data', required: true },
735735+ { name: 'k', desc: 'Percentile value between 0 and 1 inclusive', required: true },
736736+ ],
737737+ },
738738+ VAR: {
739739+ desc: 'Estimates variance based on a sample',
740740+ params: [
741741+ { name: 'number1', desc: 'First number or range', required: true },
742742+ { name: 'number2', desc: 'Additional numbers or ranges', required: false },
743743+ ],
744744+ },
745745+ VARP: {
746746+ desc: 'Calculates variance based on the entire population',
747747+ params: [
748748+ { name: 'number1', desc: 'First number or range', required: true },
749749+ { name: 'number2', desc: 'Additional numbers or ranges', required: false },
750750+ ],
751751+ },
752752+ STDEVP: {
753753+ desc: 'Calculates standard deviation based on the entire population',
754754+ params: [
755755+ { name: 'number1', desc: 'First number or range', required: true },
756756+ { name: 'number2', desc: 'Additional numbers or ranges', required: false },
757757+ ],
758758+ },
759759+760760+ // --- Financial ---
761761+ PMT: {
762762+ desc: 'Calculates the payment for a loan based on constant payments and a constant interest rate',
763763+ params: [
764764+ { name: 'rate', desc: 'The interest rate per period', required: true },
765765+ { name: 'nper', desc: 'The total number of payment periods', required: true },
766766+ { name: 'pv', desc: 'The present value (loan amount)', required: true },
767767+ { name: 'fv', desc: 'Future value (default 0)', required: false },
768768+ { name: 'type', desc: '0=end of period (default), 1=beginning', required: false },
769769+ ],
770770+ },
771771+ FV: {
772772+ desc: 'Returns the future value of an investment',
773773+ params: [
774774+ { name: 'rate', desc: 'The interest rate per period', required: true },
775775+ { name: 'nper', desc: 'The total number of payment periods', required: true },
776776+ { name: 'pmt', desc: 'The payment made each period', required: true },
777777+ { name: 'pv', desc: 'Present value (default 0)', required: false },
778778+ { name: 'type', desc: '0=end of period (default), 1=beginning', required: false },
779779+ ],
780780+ },
781781+ PV: {
782782+ desc: 'Returns the present value of an investment',
783783+ params: [
784784+ { name: 'rate', desc: 'The interest rate per period', required: true },
785785+ { name: 'nper', desc: 'The total number of payment periods', required: true },
786786+ { name: 'pmt', desc: 'The payment made each period', required: true },
787787+ { name: 'fv', desc: 'Future value (default 0)', required: false },
788788+ { name: 'type', desc: '0=end of period (default), 1=beginning', required: false },
789789+ ],
790790+ },
791791+ NPV: {
792792+ desc: 'Returns the net present value of an investment based on periodic cash flows and a discount rate',
793793+ params: [
794794+ { name: 'rate', desc: 'The discount rate per period', required: true },
795795+ { name: 'value1', desc: 'First cash flow', required: true },
796796+ { name: 'value2', desc: 'Additional cash flows', required: false },
797797+ ],
798798+ },
799799+ IRR: {
800800+ desc: 'Returns the internal rate of return for a series of cash flows',
801801+ params: [
802802+ { name: 'values', desc: 'Array of cash flows (must contain at least one positive and one negative)', required: true },
803803+ { name: 'guess', desc: 'Initial guess for the rate (default 0.1)', required: false },
804804+ ],
805805+ },
806806+807807+ // --- Lookup (additional) ---
808808+ CHOOSE: {
809809+ desc: 'Returns a value from a list based on an index number',
810810+ params: [
811811+ { name: 'index_num', desc: 'The index number (1-based) of the value to return', required: true },
812812+ { name: 'value1', desc: 'First value to choose from', required: true },
813813+ { name: 'value2', desc: 'Additional values', required: false },
814814+ ],
815815+ },
408816};
409817410818/**
+305
src/sheets/formulas.ts
···959959 return hasDefault ? pairs[pairs.length - 1] : '#N/A';
960960 }
961961962962+ // --- Information Functions ---
963963+ case 'ISNUMBER': return typeof args[0] === 'number';
964964+ case 'ISTEXT': return typeof args[0] === 'string' && !String(args[0]).startsWith('#');
965965+ case 'ISBLANK': return args[0] === null || args[0] === undefined || args[0] === '';
966966+ case 'ISERROR': {
967967+ const ev = args[0];
968968+ return typeof ev === 'string' && (ev === '#REF!' || ev === '#VALUE!' || ev === '#DIV/0!' || ev === '#N/A' || ev === '#ERROR!' || ev === '#NUM!' || ev.startsWith('#NAME?'));
969969+ }
970970+ case 'ISNA': return args[0] === '#N/A';
971971+ case 'ISLOGICAL': return typeof args[0] === 'boolean';
972972+ case 'TYPE': {
973973+ const tv = args[0];
974974+ if (typeof tv === 'number') return 1;
975975+ if (typeof tv === 'string' && tv.startsWith('#')) return 16;
976976+ if (typeof tv === 'string') return 2;
977977+ if (typeof tv === 'boolean') return 4;
978978+ if (Array.isArray(tv)) return 64;
979979+ return 1;
980980+ }
981981+ case 'N': {
982982+ const nv = args[0];
983983+ if (typeof nv === 'number') return nv;
984984+ if (typeof nv === 'boolean') return nv ? 1 : 0;
985985+ if (nv instanceof Date) return nv.getTime();
986986+ return 0;
987987+ }
988988+ case 'T': {
989989+ const tv2 = args[0];
990990+ return typeof tv2 === 'string' && !String(tv2).startsWith('#') ? tv2 : '';
991991+ }
992992+993993+ // --- Math Functions (additional) ---
994994+ case 'SUMPRODUCT': {
995995+ const spArrays = args.map(a => Array.isArray(a) ? (a as unknown[]).map(toNum) : [toNum(a)]);
996996+ const spLen = Math.min(...spArrays.map(a => a.length));
997997+ let spSum = 0;
998998+ for (let i = 0; i < spLen; i++) {
999999+ let spProd = 1;
10001000+ for (const arr of spArrays) spProd *= arr[i];
10011001+ spSum += spProd;
10021002+ }
10031003+ return spSum;
10041004+ }
10051005+ case 'PRODUCT': {
10061006+ const pn = nums(args);
10071007+ return pn.length ? pn.reduce((a, b) => a * b, 1) : 0;
10081008+ }
10091009+ case 'SIGN': {
10101010+ const sv = toNum(args[0]);
10111011+ return sv > 0 ? 1 : sv < 0 ? -1 : 0;
10121012+ }
10131013+ case 'EVEN': {
10141014+ const ev2 = toNum(args[0]);
10151015+ const evRound = ev2 >= 0 ? Math.ceil(ev2) : Math.floor(ev2);
10161016+ if (evRound % 2 === 0) return evRound;
10171017+ return ev2 >= 0 ? evRound + 1 : evRound - 1;
10181018+ }
10191019+ case 'ODD': {
10201020+ const ov = toNum(args[0]);
10211021+ const ovRound = ov >= 0 ? Math.ceil(ov) : Math.floor(ov);
10221022+ if (ovRound === 0) return ov >= 0 ? 1 : -1;
10231023+ if (Math.abs(ovRound) % 2 === 1) return ovRound;
10241024+ return ov >= 0 ? ovRound + 1 : ovRound - 1;
10251025+ }
10261026+ case 'CEILING': {
10271027+ const cNum = toNum(args[0]);
10281028+ const cSig = toNum(args[1]);
10291029+ if (cSig === 0) return 0;
10301030+ return Math.ceil(cNum / cSig) * cSig;
10311031+ }
10321032+ case 'FLOOR': {
10331033+ const fNum = toNum(args[0]);
10341034+ const fSig = toNum(args[1]);
10351035+ if (fSig === 0) return 0;
10361036+ return Math.floor(fNum / fSig) * fSig;
10371037+ }
10381038+ case 'FACT': {
10391039+ const fn2 = Math.floor(toNum(args[0]));
10401040+ if (fn2 < 0) return '#NUM!';
10411041+ if (fn2 <= 1) return 1;
10421042+ let fResult = 1;
10431043+ for (let i = 2; i <= fn2; i++) fResult *= i;
10441044+ return fResult;
10451045+ }
10461046+ case 'COMBIN': {
10471047+ const cn = Math.floor(toNum(args[0]));
10481048+ const ck = Math.floor(toNum(args[1]));
10491049+ if (ck < 0 || ck > cn || cn < 0) return '#NUM!';
10501050+ if (ck === 0 || ck === cn) return 1;
10511051+ let cResult = 1;
10521052+ for (let i = 0; i < Math.min(ck, cn - ck); i++) {
10531053+ cResult = cResult * (cn - i) / (i + 1);
10541054+ }
10551055+ return Math.round(cResult);
10561056+ }
10571057+ case 'GCD': {
10581058+ let ga = Math.abs(Math.floor(toNum(args[0])));
10591059+ let gb = Math.abs(Math.floor(toNum(args[1])));
10601060+ while (gb) { [ga, gb] = [gb, ga % gb]; }
10611061+ return ga;
10621062+ }
10631063+ case 'LCM': {
10641064+ const la = Math.abs(Math.floor(toNum(args[0])));
10651065+ const lb = Math.abs(Math.floor(toNum(args[1])));
10661066+ if (la === 0 && lb === 0) return 0;
10671067+ let lg = la, lt = lb;
10681068+ while (lt) { [lg, lt] = [lt, lg % lt]; }
10691069+ return (la / lg) * lb;
10701070+ }
10711071+ case 'QUOTIENT': {
10721072+ const qd = toNum(args[1]);
10731073+ if (qd === 0) return '#DIV/0!';
10741074+ return Math.trunc(toNum(args[0]) / qd);
10751075+ }
10761076+10771077+ // --- Trigonometric Functions ---
10781078+ case 'SIN': return Math.sin(toNum(args[0]));
10791079+ case 'COS': return Math.cos(toNum(args[0]));
10801080+ case 'TAN': return Math.tan(toNum(args[0]));
10811081+ case 'ASIN': return Math.asin(toNum(args[0]));
10821082+ case 'ACOS': return Math.acos(toNum(args[0]));
10831083+ case 'ATAN': return Math.atan(toNum(args[0]));
10841084+ case 'ATAN2': return Math.atan2(toNum(args[1]), toNum(args[0]));
10851085+ case 'DEGREES': return toNum(args[0]) * (180 / Math.PI);
10861086+ case 'RADIANS': return toNum(args[0]) * (Math.PI / 180);
10871087+10881088+ // --- Text Functions (additional) ---
10891089+ case 'PROPER': {
10901090+ return String(args[0]).toLowerCase().replace(/(?:^|\s|[^\w])\w/g, c => c.toUpperCase());
10911091+ }
10921092+ case 'REPT': return String(args[0]).repeat(Math.max(0, Math.floor(toNum(args[1]))));
10931093+ case 'EXACT': return String(args[0]) === String(args[1]);
10941094+ case 'REPLACE': {
10951095+ const rpText = String(args[0]);
10961096+ const rpStart = toNum(args[1]) - 1;
10971097+ const rpNum = toNum(args[2]);
10981098+ const rpNew = String(args[3]);
10991099+ return rpText.slice(0, rpStart) + rpNew + rpText.slice(rpStart + rpNum);
11001100+ }
11011101+ case 'CLEAN': return String(args[0]).replace(/[\x00-\x1F]/g, '');
11021102+ case 'CHAR': return String.fromCharCode(toNum(args[0]));
11031103+ case 'CODE': {
11041104+ const codeStr = String(args[0]);
11051105+ return codeStr.length > 0 ? codeStr.charCodeAt(0) : '#VALUE!';
11061106+ }
11071107+11081108+ // --- Date/Time Functions (additional) ---
11091109+ case 'HOUR': return new Date(args[0] as string | number | Date).getHours();
11101110+ case 'MINUTE': return new Date(args[0] as string | number | Date).getMinutes();
11111111+ case 'SECOND': return new Date(args[0] as string | number | Date).getSeconds();
11121112+ case 'WEEKDAY': {
11131113+ const wdDate = new Date(args[0] as string | number | Date);
11141114+ const wdType = args[1] !== undefined ? toNum(args[1]) : 1;
11151115+ const wdDay = wdDate.getDay();
11161116+ if (wdType === 1) return wdDay + 1;
11171117+ if (wdType === 2) return wdDay === 0 ? 7 : wdDay;
11181118+ if (wdType === 3) return wdDay === 0 ? 6 : wdDay - 1;
11191119+ return wdDay + 1;
11201120+ }
11211121+ case 'EDATE': {
11221122+ const edDate = new Date(args[0] as string | number | Date);
11231123+ const edMonths = toNum(args[1]);
11241124+ edDate.setMonth(edDate.getMonth() + edMonths);
11251125+ return edDate;
11261126+ }
11271127+ case 'EOMONTH': {
11281128+ const emDate = new Date(args[0] as string | number | Date);
11291129+ const emMonths = toNum(args[1]);
11301130+ emDate.setMonth(emDate.getMonth() + emMonths + 1, 0);
11311131+ return emDate;
11321132+ }
11331133+ case 'DAYS': {
11341134+ const dEnd = new Date(args[0] as string | number | Date);
11351135+ const dStart = new Date(args[1] as string | number | Date);
11361136+ return Math.round((dEnd.getTime() - dStart.getTime()) / 86400000);
11371137+ }
11381138+ case 'NETWORKDAYS': {
11391139+ const nwStart = new Date(args[0] as string | number | Date);
11401140+ const nwEnd = new Date(args[1] as string | number | Date);
11411141+ let nwCount = 0;
11421142+ const nwStep = nwStart <= nwEnd ? 1 : -1;
11431143+ const nwCur = new Date(nwStart);
11441144+ while ((nwStep > 0 && nwCur <= nwEnd) || (nwStep < 0 && nwCur >= nwEnd)) {
11451145+ const nwDay = nwCur.getDay();
11461146+ if (nwDay !== 0 && nwDay !== 6) nwCount++;
11471147+ nwCur.setDate(nwCur.getDate() + nwStep);
11481148+ }
11491149+ return nwStep > 0 ? nwCount : -nwCount;
11501150+ }
11511151+11521152+ // --- Statistical Functions (additional) ---
11531153+ case 'LARGE': {
11541154+ const lgN = nums([args[0]]).sort((a, b) => b - a);
11551155+ const lgK = toNum(args[1]);
11561156+ if (lgK < 1 || lgK > lgN.length) return '#NUM!';
11571157+ return lgN[lgK - 1];
11581158+ }
11591159+ case 'SMALL': {
11601160+ const smN = nums([args[0]]).sort((a, b) => a - b);
11611161+ const smK = toNum(args[1]);
11621162+ if (smK < 1 || smK > smN.length) return '#NUM!';
11631163+ return smN[smK - 1];
11641164+ }
11651165+ case 'RANK': {
11661166+ const rkVal = toNum(args[0]);
11671167+ const rkN = nums([args[1]]);
11681168+ const rkOrder = args[2] !== undefined ? toNum(args[2]) : 0;
11691169+ const rkSorted = [...rkN].sort((a, b) => rkOrder ? a - b : b - a);
11701170+ const rkIdx = rkSorted.indexOf(rkVal);
11711171+ return rkIdx === -1 ? '#N/A' : rkIdx + 1;
11721172+ }
11731173+ case 'PERCENTILE': {
11741174+ const pcN = nums([args[0]]).sort((a, b) => a - b);
11751175+ const pcK = toNum(args[1]);
11761176+ if (pcK < 0 || pcK > 1 || pcN.length === 0) return '#NUM!';
11771177+ const pcIdx = pcK * (pcN.length - 1);
11781178+ const pcLo = Math.floor(pcIdx);
11791179+ const pcHi = Math.ceil(pcIdx);
11801180+ if (pcLo === pcHi) return pcN[pcLo];
11811181+ return pcN[pcLo] + (pcN[pcHi] - pcN[pcLo]) * (pcIdx - pcLo);
11821182+ }
11831183+ case 'VAR': {
11841184+ const varN = nums(args);
11851185+ if (varN.length < 2) return '#DIV/0!';
11861186+ const varMean = varN.reduce((a, b) => a + b, 0) / varN.length;
11871187+ return varN.reduce((a, b) => a + (b - varMean) ** 2, 0) / (varN.length - 1);
11881188+ }
11891189+ case 'VARP': {
11901190+ const vpN = nums(args);
11911191+ if (vpN.length === 0) return '#DIV/0!';
11921192+ const vpMean = vpN.reduce((a, b) => a + b, 0) / vpN.length;
11931193+ return vpN.reduce((a, b) => a + (b - vpMean) ** 2, 0) / vpN.length;
11941194+ }
11951195+ case 'STDEVP': {
11961196+ const sdpN = nums(args);
11971197+ if (sdpN.length === 0) return '#DIV/0!';
11981198+ const sdpMean = sdpN.reduce((a, b) => a + b, 0) / sdpN.length;
11991199+ return Math.sqrt(sdpN.reduce((a, b) => a + (b - sdpMean) ** 2, 0) / sdpN.length);
12001200+ }
12011201+12021202+ // --- Financial Functions ---
12031203+ case 'PMT': {
12041204+ const pmtRate = toNum(args[0]);
12051205+ const pmtNper = toNum(args[1]);
12061206+ const pmtPv = toNum(args[2]);
12071207+ const pmtFv = args[3] !== undefined ? toNum(args[3]) : 0;
12081208+ const pmtType = args[4] !== undefined ? toNum(args[4]) : 0;
12091209+ if (pmtRate === 0) return -(pmtPv + pmtFv) / pmtNper;
12101210+ const pmtPvif = Math.pow(1 + pmtRate, pmtNper);
12111211+ return -(pmtRate * (pmtPv * pmtPvif + pmtFv)) / (pmtPvif - 1) / (1 + pmtRate * pmtType);
12121212+ }
12131213+ case 'FV': {
12141214+ const fvRate = toNum(args[0]);
12151215+ const fvNper = toNum(args[1]);
12161216+ const fvPmt = toNum(args[2]);
12171217+ const fvPv = args[3] !== undefined ? toNum(args[3]) : 0;
12181218+ const fvType = args[4] !== undefined ? toNum(args[4]) : 0;
12191219+ if (fvRate === 0) return -(fvPv + fvPmt * fvNper);
12201220+ const fvPvif = Math.pow(1 + fvRate, fvNper);
12211221+ return -(fvPv * fvPvif + fvPmt * (1 + fvRate * fvType) * ((fvPvif - 1) / fvRate));
12221222+ }
12231223+ case 'PV': {
12241224+ const pvRate = toNum(args[0]);
12251225+ const pvNper = toNum(args[1]);
12261226+ const pvPmt = toNum(args[2]);
12271227+ const pvFv = args[3] !== undefined ? toNum(args[3]) : 0;
12281228+ const pvType = args[4] !== undefined ? toNum(args[4]) : 0;
12291229+ if (pvRate === 0) return -(pvFv + pvPmt * pvNper);
12301230+ const pvPvif = Math.pow(1 + pvRate, pvNper);
12311231+ return -(pvFv + pvPmt * (1 + pvRate * pvType) * ((pvPvif - 1) / pvRate)) / pvPvif;
12321232+ }
12331233+ case 'NPV': {
12341234+ const npvRate = toNum(args[0]);
12351235+ let npvSum = 0;
12361236+ for (let i = 1; i < args.length; i++) {
12371237+ const npvVals = Array.isArray(args[i]) ? (args[i] as unknown[]).map(toNum) : [toNum(args[i])];
12381238+ for (const nvItem of npvVals) {
12391239+ npvSum += nvItem / Math.pow(1 + npvRate, i);
12401240+ }
12411241+ }
12421242+ return npvSum;
12431243+ }
12441244+ case 'IRR': {
12451245+ const irrVals = Array.isArray(args[0]) ? (args[0] as unknown[]).map(toNum) : [toNum(args[0])];
12461246+ let irrGuess = args[1] !== undefined ? toNum(args[1]) : 0.1;
12471247+ for (let iter = 0; iter < 100; iter++) {
12481248+ let irrNpv = 0, irrDnpv = 0;
12491249+ for (let i = 0; i < irrVals.length; i++) {
12501250+ irrNpv += irrVals[i] / Math.pow(1 + irrGuess, i);
12511251+ irrDnpv -= i * irrVals[i] / Math.pow(1 + irrGuess, i + 1);
12521252+ }
12531253+ if (Math.abs(irrNpv) < 1e-7) return irrGuess;
12541254+ if (irrDnpv === 0) return '#NUM!';
12551255+ irrGuess = irrGuess - irrNpv / irrDnpv;
12561256+ }
12571257+ return '#NUM!';
12581258+ }
12591259+12601260+ // --- Lookup/Logic Functions (additional) ---
12611261+ case 'CHOOSE': {
12621262+ const chIdx = Math.floor(toNum(args[0]));
12631263+ if (chIdx < 1 || chIdx >= args.length) return '#VALUE!';
12641264+ return args[chIdx];
12651265+ }
12661266+9621267 default: return `#NAME? (${name})`;
9631268 }
9641269}
+187
src/sheets/hidden-rows-cols.ts
···11+/**
22+ * Hidden Rows & Columns — pure logic for managing hidden row/column state.
33+ *
44+ * The actual Yjs storage is handled in main.ts. This module provides
55+ * testable helper functions for computing visibility, indicators, and
66+ * spacer heights when rows/columns are hidden.
77+ */
88+99+/**
1010+ * A set-like interface for hidden row/column indices.
1111+ * In main.ts this will be backed by a Yjs Y.Map<string, boolean>.
1212+ */
1313+export interface HiddenSet {
1414+ has(index: number): boolean;
1515+}
1616+1717+/**
1818+ * Build an array of row numbers to render, skipping hidden rows.
1919+ * Returns: { rows: number[], indicators: number[][] }
2020+ *
2121+ * `rows` — the rows that should be rendered (in order)
2222+ * `indicators` — groups of consecutive hidden rows. Each entry is [afterRow, count],
2323+ * meaning "after visible row `afterRow`, there are `count` hidden rows".
2424+ * If hidden rows are at the very start (before row 1 visible), afterRow = 0.
2525+ */
2626+export function computeVisibleRows(
2727+ startRow: number,
2828+ endRow: number,
2929+ hiddenRows: HiddenSet,
3030+): { rows: number[]; indicators: Array<{ afterRow: number; hiddenCount: number }> } {
3131+ const rows: number[] = [];
3232+ const indicators: Array<{ afterRow: number; hiddenCount: number }> = [];
3333+3434+ let lastVisibleRow = startRow - 1; // 0 means "before any visible row"
3535+ let hiddenRun = 0;
3636+3737+ for (let r = startRow; r <= endRow; r++) {
3838+ if (hiddenRows.has(r)) {
3939+ hiddenRun++;
4040+ } else {
4141+ if (hiddenRun > 0) {
4242+ indicators.push({ afterRow: lastVisibleRow, hiddenCount: hiddenRun });
4343+ hiddenRun = 0;
4444+ }
4545+ rows.push(r);
4646+ lastVisibleRow = r;
4747+ }
4848+ }
4949+5050+ // Trailing hidden rows
5151+ if (hiddenRun > 0) {
5252+ indicators.push({ afterRow: lastVisibleRow, hiddenCount: hiddenRun });
5353+ }
5454+5555+ return { rows, indicators };
5656+}
5757+5858+/**
5959+ * Build an array of column numbers to render, skipping hidden columns.
6060+ * Same pattern as rows.
6161+ */
6262+export function computeVisibleCols(
6363+ startCol: number,
6464+ endCol: number,
6565+ hiddenCols: HiddenSet,
6666+): { cols: number[]; indicators: Array<{ afterCol: number; hiddenCount: number }> } {
6767+ const cols: number[] = [];
6868+ const indicators: Array<{ afterCol: number; hiddenCount: number }> = [];
6969+7070+ let lastVisibleCol = startCol - 1;
7171+ let hiddenRun = 0;
7272+7373+ for (let c = startCol; c <= endCol; c++) {
7474+ if (hiddenCols.has(c)) {
7575+ hiddenRun++;
7676+ } else {
7777+ if (hiddenRun > 0) {
7878+ indicators.push({ afterCol: lastVisibleCol, hiddenCount: hiddenRun });
7979+ hiddenRun = 0;
8080+ }
8181+ cols.push(c);
8282+ lastVisibleCol = c;
8383+ }
8484+ }
8585+8686+ if (hiddenRun > 0) {
8787+ indicators.push({ afterCol: lastVisibleCol, hiddenCount: hiddenRun });
8888+ }
8989+9090+ return { cols, indicators };
9191+}
9292+9393+/**
9494+ * Calculate the spacer height adjustment for hidden rows in a virtual scroll range.
9595+ * Returns the number of pixels to subtract from the spacer because those rows are hidden.
9696+ */
9797+export function hiddenRowsSpacerAdjustment(
9898+ startRow: number,
9999+ endRow: number,
100100+ hiddenRows: HiddenSet,
101101+ rowHeight: number,
102102+): number {
103103+ let count = 0;
104104+ for (let r = startRow; r <= endRow; r++) {
105105+ if (hiddenRows.has(r)) count++;
106106+ }
107107+ return count * rowHeight;
108108+}
109109+110110+/**
111111+ * Given a right-click on a row header adjacent to hidden rows,
112112+ * determine which hidden rows to unhide.
113113+ *
114114+ * Strategy: if there are hidden rows immediately above or below the clicked row,
115115+ * unhide them all.
116116+ */
117117+export function getAdjacentHiddenRows(
118118+ clickedRow: number,
119119+ totalRows: number,
120120+ hiddenRows: HiddenSet,
121121+): number[] {
122122+ const result: number[] = [];
123123+124124+ // Check above
125125+ for (let r = clickedRow - 1; r >= 1; r--) {
126126+ if (hiddenRows.has(r)) result.push(r);
127127+ else break;
128128+ }
129129+130130+ // Check below
131131+ for (let r = clickedRow + 1; r <= totalRows; r++) {
132132+ if (hiddenRows.has(r)) result.push(r);
133133+ else break;
134134+ }
135135+136136+ return result.sort((a, b) => a - b);
137137+}
138138+139139+/**
140140+ * Same as getAdjacentHiddenRows but for columns.
141141+ */
142142+export function getAdjacentHiddenCols(
143143+ clickedCol: number,
144144+ totalCols: number,
145145+ hiddenCols: HiddenSet,
146146+): number[] {
147147+ const result: number[] = [];
148148+149149+ for (let c = clickedCol - 1; c >= 1; c--) {
150150+ if (hiddenCols.has(c)) result.push(c);
151151+ else break;
152152+ }
153153+154154+ for (let c = clickedCol + 1; c <= totalCols; c++) {
155155+ if (hiddenCols.has(c)) result.push(c);
156156+ else break;
157157+ }
158158+159159+ return result.sort((a, b) => a - b);
160160+}
161161+162162+/**
163163+ * Check whether a row is at a hidden boundary (adjacent to hidden rows).
164164+ * Used to decide whether to show "Unhide rows" in the context menu.
165165+ */
166166+export function isAtHiddenRowBoundary(
167167+ row: number,
168168+ totalRows: number,
169169+ hiddenRows: HiddenSet,
170170+): boolean {
171171+ if (row > 1 && hiddenRows.has(row - 1)) return true;
172172+ if (row < totalRows && hiddenRows.has(row + 1)) return true;
173173+ return false;
174174+}
175175+176176+/**
177177+ * Check whether a column is at a hidden boundary.
178178+ */
179179+export function isAtHiddenColBoundary(
180180+ col: number,
181181+ totalCols: number,
182182+ hiddenCols: HiddenSet,
183183+): boolean {
184184+ if (col > 1 && hiddenCols.has(col - 1)) return true;
185185+ if (col < totalCols && hiddenCols.has(col + 1)) return true;
186186+ return false;
187187+}