Commit Graph
32 Commits
Author SHA1 Message Date
wkhandClaude Sonnet 5 6a62dccedd Meta-Beschreibung: "Case-/Designstudie"-Framing entfernt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 23:19:15 +02:00
wkhandClaude Sonnet 5 76587c1cee Größenänderung einer "Feste Größe"-Tabelle steuert Zeilen-/Spaltenzahl
Der SE-Griff einer Tabelle im "Feste Größe"-Modus ändert nicht mehr nur
den scrollbaren Pixel-Viewport, sondern direkt die Zeilen-/Spaltenzahl:
Vergrößern fügt leere Zeilen/Spalten hinzu, Verkleinern entfernt sie —
aber nie über echten Zellinhalt hinweg (klemmt auf die kleinste Größe,
die den vorhandenen Inhalt noch vollständig zeigt). Live-Vorschau während
des Ziehens bleibt der günstige reine Viewport-Resize (kein Workbook-
Rebuild pro Mausbewegung); die eigentliche Zeilen-/Spaltenänderung
committet erst beim Loslassen. Reine UI-Änderung (CanvasObject.vue),
nutzt die bestehenden setTableRows/setTableCols weiter.

Playwright-live-verifiziert, kein Komponenten-Test-Setup vorhanden
(README dokumentiert die Lücke).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 22:50:27 +02:00
wkhandClaude Sonnet 5 aef8f1349c AND/OR/NOT, Fehler-Literale und Zeilen/Spalten einfügen/löschen
- AND(...)/OR(...)/NOT(...) als gewöhnliche Funktionen (kein Kurzschluss-
  Sonderfall wie IF/XLOOKUP nötig, werten wie in echtem Excel alle
  Argumente eager aus). Dabei die bisher dreifach duplizierte
  Wahrheits-Prüfung zu functions.ts#isTruthy() zusammengezogen, jetzt
  von FUNCTIONS.IF, evalIf() und dem Block-IF-Statement gemeinsam genutzt.
  Schließt "kein AND/OR für zusammengesetzte Bedingungen" aus den
  Bekannten Grenzen, auch nutzbar in "Mit Schleife"-IF-Bedingungen.

- Neue Grammatik-Ergänzung: ErrorLiteralNode. Jeder der sieben Fehlercodes
  (#REF, #DIV/0!, #NAME?, #ERR, #CIRC, #VALUE!, #N/A) lässt sich jetzt
  direkt in eine Formel tippen (=IF(A1=0;#N/A;A1)) und wertet unbedingt zu
  genau diesem Fehler aus.

- Zeilen/Spalten einfügen/löschen mitten im Gitter (Rechtsklick auf einen
  Zeilen-/Spaltenkopf), nicht nur am Rand wie bisher: Zellinhalte, Formate
  und Zeilenhöhen/Spaltenbreiten verschieben sich innerhalb der Tabelle,
  und jede Formel im gesamten Dokument, die die Tabelle referenziert (auch
  über Tabelle!Zelle/Blatt!Tabelle::Zelle-Qualifier aus anderen Tabellen),
  wird automatisch mitgezogen. Ein Bereich, der die Stelle überspannt,
  wächst/schrumpft entsprechend; eine Referenz, die exakt die gelöschte
  Zeile/Spalte trifft, wird zu einem echten #REF-Literal im Formeltext
  (nutzt die neue ErrorLiteralNode-Grammatik). $-Sperren bleiben dabei
  bewusst wirkungslos (anders als beim Kopieren), unveränderte Referenzen
  bleiben byte-identisch. Token-Level-Splice wie shiftFormula (Kopieren/
  Ausschneiden), keine AST-Neuserialisierung.

406 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 22:34:33 +02:00
wkhandClaude Sonnet 5 2c54537427 Text-Vergleich, &-Verkettung und benannte Bereiche
- Vergleichsoperatoren (= <> > < >= <=) unterscheiden jetzt Text- von
  Zahlenvergleich statt jede Seite zwangsweise durch toNumber() zu
  schicken (=A1="Nord" gab vorher #VALUE! statt 1); ermöglicht
  Text-Kriterien in selbstgebauten COUNTIF/SUMIF/AVERAGEIF-Funktionen
  (neue README-Sektion "SUMIF/COUNTIF/AVERAGEIF nachbauen").
- Neuer &-Operator (Excel-Präzedenz zwischen Vergleich und +/-) plus
  CONCAT/CONCATENATE als Funktions-Äquivalent.
- Benannte Bereiche: neuer Toolbar-Button "🏷" verwaltet dokumentweite
  Namen für eine feste Zelle/einen festen Bereich (z. B. Umsatz_Q1statt
  Umsatz!B2:B6), unqualifiziert in jeder Top-Level-Formel nutzbar.
  Live gegen die Tabellen-ID aufgelöst (übersteht Tabellen-Umbenennung),
  bewusst nicht innerhalb einer benutzerdefinierten Funktion selbst
  nutzbar (Aufrufer können sie aber als Argument übergeben).
- Nebenbei: COUNT2s Autovervollständigungs-Hint korrigiert (zeigte
  "COUNT(...)" statt "COUNT2(...)").

374 Tests, vue-tsc/eslint/build sauber, Playwright-live-verifiziert.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 21:59:47 +02:00
wkh 9015a991aa adding count2 2026-09-03 08:49:20 +02:00
wkhandClaude Sonnet 5 0b7602b874 Funktions-Autovervollständigung in der Formelbearbeitung
Beim Tippen eines Funktionsnamens (nach "=", einem Operator, "(" oder
";" -- nicht bei einem Zellbezug wie A1) zeigt sowohl die Formelleiste
als auch der In-Zellen-Editor jetzt ein Dropdown mit passenden
eingebauten UND eigenen Funktionen samt Kurzsignatur. ↑/↓ navigieren,
Tab/Enter/Klick übernehmen (fügt "NAME(" ein), Esc schließt nur das
Dropdown, ohne die Bearbeitung abzubrechen.

- engine/refs.ts: neue reine Funktion currentIdentifierAtCaret() --
  strukturell wie das schon vorhandene cycleReferenceLocks (=
  abstreifen, tokenize, am Cursor nachsehen), aber nur der Text bis
  zum Cursor wird tokenisiert und nur das letzte Token zählt; ein
  IDENT, das bis an den Cursor reicht und nicht wie ein Zellbezug
  aussieht, ist der Funktionsname. tokenize() hängt ein EOF-Token an
  -- muss beim Suchen des "letzten Tokens" herausgefiltert werden
  (von den eigenen Tests aufgedeckt).
- engine/functions.ts: BUILTIN_SIGNATURES (Name -> Kurzsignatur je
  eingebauter Funktion), gegen FUNCTIONS' Keys testabgesichert, damit
  eine künftige neue Funktion ohne Eintrag hier nicht still im
  Dropdown fehlt.
- store/document.ts: functionSuggestions(prefix) kombiniert eingebaute
  + dokumenteigene Funktionen, case-insensitiv gefiltert, sortiert.
- composables/useFormulaAutocomplete.ts: pro Editor-Instanz eigener
  State (suggestions/activeIndex), orchestriert die drei Bausteine
  oben; die Übernahme eines Vorschlags läuft über dieselbe
  replace(start,end,value)-Mechanik, die Point-Mode für den Bezug-
  Einfügeklick schon nutzt -- keine neue Einfüge-Infrastruktur nötig.
- components/FormulaSuggest.vue: geteiltes, rein präsentationelles
  Dropdown (position:absolute; top:100% relativ zum Editor-Wrapper,
  kein Caret-Pixel-Tracking).
- FormulaBar.vue + TableGrid.vue: gleiches Einbindungsmuster in
  beiden Editoren (Wrapper bekommt position:relative, onInput ruft
  autocomplete.update(), onKeydown fängt Pfeiltasten/Tab/Enter/Esc
  vor der bestehenden Logik ab, wenn Vorschläge offen sind, onBlur
  schließt das Dropdown).

Tests: 13 neue (refs.spec.ts: 7 Fälle für currentIdentifierAtCaret;
functions.spec.ts neu: Signatur-Tabelle deckungsgleich mit FUNCTIONS;
document.spec.ts: functionSuggestions Filter/Mischung/Sortierung).
318 -> 331 Tests, alle grün. vue-tsc/eslint/build sauber. Live per
Playwright verifiziert in Formelleiste UND In-Zellen-Editor: Filterung,
Pfeiltasten-Navigation, Tab-Übernahme, Maus-Klick-Übernahme, Esc
schließt nur das Dropdown, eigene Funktion (MARGE) erscheint neben
eingebauten (MAX) korrekt einsortiert.

READMEs aktualisiert (neuer Abschnitt "Funktions-Autovervollständigung",
Tastaturtabelle, Testliste, Bekannte Grenzen -- dabei eine veraltete
Aussage korrigiert, die Autovervollständigung für benutzerdefinierte
Funktionen noch als "bewusst fehlend" auflistete).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 08:43:57 +02:00
wkhandClaude Sonnet 5 552f1cfae8 XLOOKUP als Formel, plus überspringbare optionale Argumente (;;)
Neue eingebaute Funktion XLOOKUP(Suchwert; Suchbereich; Rückgabebereich;
[WennNichtGefunden]; [Vergleichsmodus]; [Suchmodus]).

- Grammatik-Erweiterung (allgemein, nicht XLOOKUP-spezifisch): eine leere
  Argumentstelle zwischen zwei ";" (oder vor der schließenden ")") parst
  jetzt als neuer OmittedNode statt eines Parse-Fehlers -- Excels
  "dieses optionale Argument auslassen"-Idiom, z. B.
  XLOOKUP(x;a;b;;-1). Wertet überall wie eine leere Zelle aus (null);
  expand()/substitute() (userFunctions.ts) behandeln ihn als Blatt,
  collectRefs() ignoriert ihn stillschweigend.
- evaluator.ts: XLOOKUP wird -- wie IF -- vor dem generischen eager/
  flatten-Dispatch abgefangen (evalXLookup()), aus zwei Gründen: (1)
  WennNichtGefunden muss lazy ausgewertet werden (Kurzschluss wie bei
  IF), (2) Suchbereich/Rückgabebereich dürfen nicht in eine gemeinsame
  flache Werteliste geraten -- der generische Pfad würde die Grenze
  zwischen beiden Bereichen zerstören. Eigene xlookupEquals()
  (case-insensitiv/getrimmt für Text, kein Zahl-Text-Crossmatch) statt
  des `=`-Operators, der über toNumber() koerziert und nie auf Text
  träfe. Vergleichsmodus -1/1 (exakt/nächstkleiner/-größer, numerisch),
  Suchmodus 1/-1 (Richtung); Excels Platzhalter- und Binärsuch-Modi
  bewusst nicht unterstützt.
- functions.ts: Platzhalter-Eintrag für Namensreservierung (analog IF).
- types.ts: neuer Fehlercode #N/A (kein Treffer, kein
  WennNichtGefunden) -- dokumentierte Erweiterung, wie zuvor #VALUE!.
- collectRefs() brauchte keine Änderung -- sein generischer case 'func'
  läuft schon konservativ durch alle Argumente.

Tests: 22 neue (parser.spec.ts: OmittedNode leading/middle/trailing;
evaluator.spec.ts: SUM(1;;3)-Smoke plus 15 XLOOKUP-Fälle; workbook.spec.ts:
Neuberechnung bei Änderung in Such- ODER Rückgabebereich, keine bei
unbeteiligten Zellen; document.spec.ts: Namenskollision). 298 -> 318
Tests, alle grün. vue-tsc/eslint/build sauber. Live per Playwright
verifiziert (Treffer, #N/A, WennNichtGefunden, Kurzschluss-Beweis,
übersprungenes Argument für Vergleichsmodus, Neuberechnung nach Umbenennen
einer Suchbereich-Zelle inkl. nachträglich ausgelöstem #DIV/0! im vorher
nicht ausgewerteten WennNichtGefunden-Zweig).

READMEs aktualisiert (Funktionen, Fehlermodell, neue Abschnitte "Leere
Argumentstelle" und "Nachschlagen (XLOOKUP)" in engine/README.md,
Bekannte Grenzen, Testzahl; EBNF-Grammatik in parser.ts kommentiert).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 08:21:11 +02:00
wkhandClaude Sonnet 5 af01aa5e53 Locale-unabhängiges ISO-Datumsformat als Zellformat
Neues CellNumFmt 'dateiso' im Zahlenformat-Dropdown ("Datum (ISO) ·
2026-09-02"): zeigt ein Datum immer als YYYY-MM-DD, unabhängig von der
aktiven Locale (Zelle/Blatt/Dokument) -- anders als "Datum", das der
jeweiligen Locale folgt. Nützlich für export-nahe/sortierbare
Datumsspalten.

- store/types.ts: CellNumFmt um 'dateiso' erweitert.
- store/format.ts: die manuelle YYYY-MM-DD-Formatierung, die bisher nur
  für das 'plain'-Pseudo-Locale galt, in isoDateString() extrahiert und
  von einem neuen 'dateiso'-Fall in formatValue() wiederverwendet.
  NUM_FORMATS bekam den neuen Eintrag -- das Zahlenformat-Dropdown
  (SettingsPanel.vue) und die JSON-Validierung (isCellNumFmt) sind
  bereits generisch über NUM_FORMATS, keine Änderung nötig.

Tests: formatValue mit 'dateiso' über mehrere Locales (inkl. 'plain').
297 -> 298 Tests, alle grün. vue-tsc/eslint/build sauber. Live per
Playwright verifiziert: Zelle auf "Datum (ISO)" umgestellt zeigt
2026-09-02 und bleibt das auch nach Wechsel der Dokument-Locale auf
en-US, während eine andere Zelle ohne dieses Format der Locale folgt
(09/02/2026).

READMEs aktualisiert (Zahlenformat-Liste, "Datum & Zeit"-Abschnitt in
engine/README.md, Testzahl).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 23:31:18 +02:00
wkhandClaude Sonnet 5 e6cb18ab3c Datum- & Zeit-Formatierung: Serial-Zahlen, Auto-Erkennung, DATE/TIME/TODAY/NOW
Wie in jeder Tabellenkalkulation ist ein Datum intern einfach eine Zahl
("Serial": Tage seit 1899-12-30, Uhrzeit als Bruchteil eines Tages) --
Datums-Arithmetik (=fällig - rechnungsdatum, =rechnungsdatum + 30)
funktioniert dadurch automatisch, ganz ohne Änderung an Operatoren.

- Neues Modul engine/dates.ts: dateToSerial/timeSerial (Bausteine für
  DATE()/TIME(), Überlauf wie Excel erlaubt), todaySerial/nowSerial
  (lokales Kalenderdatum), serialToUtcDate (Anzeige), parseDateTimeLiteral
  (Eingabe-Erkennung -- bewusst nur ISO/Punkt-Daten + Uhrzeit, KEINE
  Schrägstrich-Daten, da Tag-/Monat-zuerst über die unterstützten Locales
  nicht eindeutig auflösbar ist; ein zu einer ungültigen/rollierten
  Kalenderdatum führender Kandidat wird strikt verworfen statt lautlos
  zu überlaufen).
- workbook.ts: literalValue() erkennt Datum/Zeit vor dem Zahl-Test und
  liefert direkt den Serial.
- functions.ts: neue eingebaute Funktionen DATE(jahr;monat;tag),
  TIME(std;min;sek), TODAY(), NOW() -- TODAY/NOW sind bewusst nicht
  volatil (dokumentierte Grenze: diese Engine rechnet rein
  vorgänger-getrieben neu, ohne Timer).
- store/types.ts + format.ts: CellNumFmt um 'date'/'time'/'datetime'
  erweitert; formatValue rendert über Intl.DateTimeFormat (UTC-Zeitzone,
  pro Locale), 'plain' bekommt eine manuelle, locale-neutrale
  Formatierung statt der Browser-Locale.
- store/document.ts: setCellInput erkennt eine getippte Datums-/
  Zeit-Eingabe automatisch und setzt das passende Zellformat -- wie
  Excel/Numbers --, überschreibt aber nie ein bereits explizit gesetztes
  Format.
- Zahlenformat-Dropdown (SettingsPanel) und JSON-Validierung
  (isCellNumFmt) brauchten keine Änderung -- beide sind bereits generisch
  über NUM_FORMATS.

Tests: 32 neue (dates.spec.ts neu; workbook.spec.ts: literalValue +
DATE/TIME/TODAY/NOW-Formeln inkl. Datums-Arithmetik; format.spec.ts neu:
formatValue über mehrere Locales; document.spec.ts: Auto-Format beim
Eintippen, überschreibt nie ein gesetztes Format, Namenskollision mit
den neuen Funktionen). 265 -> 297 Tests, alle grün. vue-tsc/eslint/build
sauber. Live per Playwright verifiziert (Auto-Erkennung, Formel-Ergebnisse
ohne Auto-Format, Datums-Arithmetik, Schrägstrich-Daten bleiben Text).

READMEs aktualisiert (Funktionen, Zahlenformat, neuer "Datum & Zeit"-
Abschnitt in engine/README.md, Bekannte Grenzen, Testzahl; nebenbei eine
veraltete Aussage zu Vergleichsoperatoren in "Bekannte Grenzen" korrigiert
-- die gibt es seit der IF-Funktion bereits).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 23:24:39 +02:00
wkhandClaude Sonnet 5 ec85c371e8 IF-Funktion als Formel: echte Kurzschluss-Auswertung
Neue eingebaute Funktion IF(Bedingung; Dann; Sonst?), nutzbar in jeder
gewöhnlichen Zellformel (nicht nur in "Mit Schleife"-Funktionskörpern).

- functions.ts: IF als Platzhalter-Eintrag registriert, nur damit
  hasFunction('IF') greift (Namensreservierung -- ein Benutzer kann
  keine gleichnamige eigene Funktion mehr anlegen) und als defensiver
  Fallback. Trägt einen Kommentar, der auf die echte Implementierung
  verweist.
- evaluator.ts: die echte, kurzschließende Auswertung sitzt in
  evalScalar()s case 'func', VOR dem generischen eager-args-Pfad.
  evalIf() wertet nur die Bedingung immer aus, danach ausschließlich
  den genommenen Zweig -- der andere Zweig wird nie evaluiert, ein
  Fehler darin (z. B. =IF(B1=0; 0; A1/B1)) schlägt also nie durch.
  Gleiche Wahrheits-Konvention wie überall sonst im Engine (Zahl != 0
  bzw. nicht-leerer Text = wahr).
- collectRefs() bleibt unverändert: sein generischer case 'func' läuft
  bereits konservativ durch alle Argumente, unabhängig vom zur
  Laufzeit genommenen Zweig -- exakt wie Excels IF().

Tests: 8 neue (evaluator.spec.ts: beide Zweige, 2-/3-Argument-Form,
Kurzschluss-Beweis, Fehler in der Bedingung, Arity-Fehler, Vergleiche
als Bedingung; document.spec.ts: Namenskollision mit eingebautem IF).
257 -> 265 Tests, alle grün. vue-tsc/eslint/build sauber. Live per
Playwright verifiziert (inkl. Kurzschluss- und Neuberechnungsverhalten).

READMEs aktualisiert (Funktionen-Liste, Auswertungsmodell, Testzahl).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 23:00:45 +02:00
wkhandClaude Sonnet 5 3fd5a84701 Move zoom control into the status bar, right end
Was a floating pill pinned to the bottom-right corner of the canvas
(SheetCanvas.vue's .zoom-ctl); now lives as the last element of the
status bar (StatusBar.vue), after a thin divider, in the same combined
bottom row as the sheet tabs. Same three actions (−, current % as a
button that resets to 100%, +), same store.activeZoom/setZoom.
Ctrl/Cmd+scroll-to-zoom on the canvas is unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:46:25 +02:00
wkhandClaude Sonnet 5 01ea18106c Real control flow for "Mit Schleife" functions: DO/END, IF/ELSE/END, RETURN anywhere
Replaces the previous FOR-only, single-statement-body, static-unrolling
design with genuine control flow: FOR var IN source DO <block> END loop
bodies with any number of statements (including nested loops/IFs),
IF cond THEN <block> [ELSE <block>] END branching (nestable in any
block), and RETURN as an ordinary statement usable anywhere -- it
aborts execution immediately with its value, including breaking out of
a loop early.

This required a real, if small, architecture change: an IF branch or an
early RETURN depends on actual cell values, which the previous "unroll
everything into one static formula at parse time" approach can't
resolve (loop *counts* are knowable up front from a range's size;
branch *outcomes* aren't). A kind: 'block' call is now inlined only as
far as resolving nested function calls (expandBlock, parse-time, same
inside-out principle as before) and wrapped in a new BlockCallNode;
its own control flow is interpreted at evaluation time by a small
tree-walking interpreter added to evaluator.ts (execBlock/execStatement,
a thrown ReturnSignal for early exit), reusing userFunctions.ts's
substitute() (now exported) to resolve parameter/local-variable names
against the current environment. Dependency tracking stays fully
correct and conservative: collectRefs() walks into a BlockCallNode's
block (new collectRefsInBlock), so a cell referenced only in a branch
that doesn't happen to run is still a tracked precedent -- same
principle as an ordinary IF() in any spreadsheet.

Also added: comparison operators (= <> > < >= <=, result 1/0) as a new,
loosest-binding precedence level in the *general* expression grammar
(not just inside a block's IF) -- needed for IF conditions to be useful
at all. A definition-time static check (blockAlwaysReturns) rejects a
function at save time when it's not obviously guaranteed to reach
RETURN on every path (an IF without an ELSE, or one where only one
branch returns); the interpreter's own runtime "ended without RETURN"
-> #ERR remains the actual enforcement for cases the static check can't
see (e.g. a loop over an empty range).

Nested loops (FOR inside FOR) and nested IFs work as an emergent
property of the recursive statement grammar -- verified with dedicated
tests, not just assumed.

257 tests passing (30 net new/rewritten for this change), vue-tsc/
eslint/build clean. Verified live in the running app: a FIRSTOVER(range;
threshold) function using a FOR loop with a nested IF and an early
RETURN, confirmed it returns the first match, confirmed changing a
cell *past* the match point doesn't affect the result while changing
the match point itself does, and confirmed a function without a
guaranteed RETURN is rejected inline at save time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 22:41:55 +02:00
wkhandClaude Sonnet 5 6daea9f956 Add FOR-loop "code" bodies to user-defined functions (kind: 'block')
A custom function body could previously only be a single expression --
no way to iterate over a range's elements to build something like
PRODUCT (multiply every cell together), since none of the built-ins
(SUM/AVG/MIN/MAX/COUNT) do that and a body can only compose existing
functions, not define a new reduction. This adds a second body kind,
'block': a sequence of `name = expression` assignments and
`FOR var IN source DO name = expression` loops, ending in
`RETURN expression`. Flat scope -- every introduced name stays visible
for the rest of the body, including after its own loop, holding its
last iteration's value.

No runtime loop in the evaluator: a loop's source size is already known
once the caller's real argument is substituted in, so
userFunctions.ts's new compileBlock() unrolls every FOR loop into a
single nested expression (a left fold) at the same parse-time inlining
point Phase 1 already used -- the result is exactly the same shape of
static AST as any other inlined function call, so evaluator.ts and
graph.ts need no changes, dependency edges on every unrolled cell are
picked up automatically, and a bounded MAX_UNROLL_STEPS (10,000) keeps
it crash/hang-free. parser.ts's parseFunctionBlock() parses the new
grammar (a purely additive EQUALS lexer token; FOR/IN/DO/RETURN are
plain case-matched IDENTs, not new reserved tokens) into a BlockNode
kept outside the existing Node union, so no existing switch needs a
new case.

Store: CustomFunction/FunctionDef gain `kind: 'expression' | 'block'`
(defaulted for every function saved before this existed, including via
JSON import). validateCustomFunction validates the new grammar's local/
loop-variable names the same way as parameters (not cell-shaped, no
reserved words, no shadowing a parameter -- but reusing the same local
across statements, the accumulator pattern, is correctly not a
collision) and that every FOR source is structurally a parameter, cell,
or range. UI: a "𝑓 Funktionen" form toggle between "Einfache Formel"
and "Mit Schleife", with its own placeholder/hint (a PRODUCT example)
and a small badge on block-kind rows in the list.

237 tests passing (34 new), vue-tsc/eslint/build clean. Verified live
in the running app: defined PRODUCT via the loop UI, called
=PRODUCT(Umsatz!B2:E6), confirmed the correct value and recalculation
on a change to a cell inside the range, and an inline rejection for an
invalid FOR source at save time.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 21:58:59 +02:00
wkhandClaude Sonnet 5 7340307a70 Add a sheet qualifier: "Blatt!Tabelle::Zelle", table names unique per sheet
Table names were document-unique; now they're unique per sheet only --
two different sheets may each hold a table called "Umsatz". A plain,
unqualified reference (Umsatz!B2 / Umsatz::B2, unchanged from before)
resolves to the table on the SAME sheet as the formula referencing it
when the name is otherwise ambiguous, falling back to the single
document-wide match when it isn't ambiguous at all -- exactly as
before for every existing formula. The new explicit 3-part form
(Analyse!Umsatz::B2, quoted where a name has spaces) always targets
the named sheet's table regardless of where the formula itself lives,
for the case that's still genuinely ambiguous.

Engine: parser.ts's afterFirstQualifierToken() disambiguates via
lookahead -- "X!Y::" (a name, then "::") is the new 3-part sheet+table
form; a bare "X!Y" keeps its original single-qualifier meaning, so
every formula written before this exists still parses identically.
CellNode/RangeNode gain an optional sheetQualifier field.
Workbook.resolveTableName(name, sheetName?) implements the resolution
policy; TableModel gains sheetName. precedentsOf() now also sets
homeTableId (previously only evaluateCell did), so graph edges use the
same same-sheet-preference resolution an evaluation would.

Store: table name uniqueness checks (addTable, renameObject) are now
scoped to the target sheet instead of the whole document. Point-mode's
qualifyRef() inserts the full "Blatt!Tabelle::" form only when the
clicked table's name is genuinely ambiguous and lives on a different
sheet than the editor; otherwise the simple form, unchanged.

200 tests passing (19 new), vue-tsc and eslint clean, verified live in
the running app: renamed a Blatt-2 table to the same name as Analyse's
seed "Umsatz", confirmed an unqualified reference resolves per-sheet,
an explicit sheet-qualified one always targets the named sheet, and
the original sheet's own formulas are unaffected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:16:21 +02:00
wkhandClaude Sonnet 5 fdde4ed5a7 Show a range-handling pattern in the function-editing UI
A user function parameter already worked as a range when passed
straight into a range-capable call (SUM/AVG/MIN/MAX/another user
function) -- expandUserFunctions splices the caller's RangeNode in
unchanged, so it behaves exactly like a normal formula (and #VALUE!
outside a function-argument position, also exactly like normal). Added
2 tests confirming this (incl. recalculation on a change inside the
passed range) and a hint under the Formel field in
CustomFunctionsModal.vue showing the pattern (SPANNE(bereich) =
MAX(bereich) - MIN(bereich)), since nothing surfaced that this was
possible.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:59:56 +02:00
wkhandClaude Sonnet 5 1046b22e96 Show the selected range, not just the active cell, in status/formula bar
New store computed selectionRangeName: for a single cell it's identical
to activeCellName ("Umsatz::B2"), for a multi-cell selection it shows
the full primary rectangle ("Umsatz::B2:E6"), and a discontiguous
multi-selection gets a "+N" suffix for the further rectangles.
activeCellName itself is untouched (still anchor-only, since that's what
a formula/edit actually targets) -- only the formula bar's name field and
the status bar's range label switch to the new computed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:49:35 +02:00
wkhandClaude Sonnet 5 85a8687020 Merge sheet tabs and status bar into one row
SheetTabs and StatusBar were two stacked full-width bars, each with its
own background/border. Wrapped them in a shared .bottom-bar flex row in
App.vue instead: the tab list shrinks and scrolls internally to make
room, the status bar is pinned to the right and scrolls internally
rather than wrapping, so the combined row stays a single line even at
narrow (375px) viewports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:43:48 +02:00
wkhandClaude Sonnet 5 bcc0e8252e Toolbar: insert/functions buttons as icon-only pictograms
Tabelle/Diagramm/Text/Funktionen now show only their glyph (▦ ▤ ¶ 𝑓,
matching the icons already used for object kinds in CanvasObject.vue)
instead of a text label, with title + aria-label tooltips for
accessibility. New shared .icon-btn style for the compact square shape.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:41:15 +02:00
wkhandClaude Sonnet 5 c3fde283c8 Add user-defined functions, editable exclusively via a new UI
Document-wide, named formula functions (e.g. MARGE(revenue; cost) =
(revenue - cost) / revenue) callable from any cell like a built-in
(=MARGE(Umsatz!B2; Umsatz!C2)). Created, edited and deleted only through
a new "𝑓 Funktionen" toolbar button -> modal (list + shared edit form),
never via JSON handedit.

Engine: a call is inlined into the caller's AST at parse time
(src/engine/userFunctions.ts's expandUserFunctions), so evaluator.ts and
graph.ts need no changes -- dependencies on cells referenced only inside
a function body (not in the call) are picked up for free. parser.ts
gains an optional paramNames argument to parseFormula() producing a new
ParamNode leaf, used only when parsing a function body.

Store: addCustomFunction/updateCustomFunction/deleteCustomFunction with
inline (not toast) validation -- name/parameter shape, no collision with
a built-in or another function, the body parses, and a definition-time
cycle check across the whole call graph. Renaming/deleting a function
mirrors table rename/delete: existing calls are not rewritten, they
become #NAME? on the next rebuild. Fully undo/redo-able and part of the
JSON document (customFunctions: [], migrated for v1 and older v2 docs).

178 tests passing (32 new), vue-tsc and eslint clean. Manually verified
end-to-end in the running app via Playwright: define/call/recalculate,
rename -> #NAME?, a closing cycle rejected inline at save, undo restores
the function library.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:37:04 +02:00
wkhandClaude Sonnet 5 fb5915d5be Add formula "point mode": click/drag a cell to insert its reference
Excel/Numbers-style reference picking: while editing a formula (buffer
starts with "="), a click on any grid cell -- in the same table or a
completely different one -- inserts its reference at the caret instead of
changing the selection; a drag inserts/grows a range reference live as you
drag. A further click/drag before typing anything replaces the just-inserted
reference; typing anything (";" to separate SUM() arguments included, but
not special-cased -- any character ends the window, same as Excel) makes the
next click append a fresh one instead.

- document.ts: whichever editor is open (in-cell editor or formula bar)
  registers a small handle (home tableId + read/replace the buffer);
  pointClick()/pointDrag() insert or replace a reference into it, qualifying
  with `Name!` (quoted if needed) when the clicked table differs from the
  editor's own. formulaPointModeActive() gates it on the buffer being a
  formula; noteFormulaTyped() (wired to real typing, not the programmatic
  inserts) ends the "replace" window.
- TableGrid.vue: onCellMouseDown checks point mode before its normal
  selection/edit handling, tracking a drag the same way normal selection
  does; dblclick during point mode no longer hijacks into editing that cell.
- FormulaBar.vue: registers on focus, same click/drag targeting works from
  there without disturbing the grid's own selection.

Verified interactively against the dev server with Playwright, including a
real bug the store-level tests (a mocked editor handle, no real DOM) missed
entirely: preventDefault() alone kept focus on the editor, but the mousedown
still bubbled to CanvasObject.vue's own handler, which selects/brings the
clicked table to front -- closing the very editor being pointed into. Fixed
with stopPropagation() alongside it. After that fix: same-table and
cross-table click/drag both insert correctly-qualified references, a second
click replaces before typing and appends after, and the live selection/
active object are untouched by a point-mode click.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:41:27 +02:00
wkhandClaude Sonnet 5 5a06d08a6a Implement document-wide undo/redo
Snapshot-based, like the existing JSON export/import round-trip
(snapshot()/replaceDocument()) rather than a patch/command log -- simple and
provably correct for a document this size.

- document.ts: a deep watch on `doc` tracks a "content fingerprint" (sheets +
  locale + csvDelimiter, deliberately excluding selection/activeObjectId, so
  moving the cursor is never an undo step) and debounces commits by 150ms --
  long enough to coalesce a resize-drag's per-mousemove calls into one step,
  short enough that two genuinely separate quick actions don't merge. A new
  edit after an undo immediately drops the redo branch (not debounced, so a
  bound button is never briefly wrong). undo()/redo() flush any pending
  change first, then pop/apply a snapshot via a new applyDocumentState() core
  that replaceDocument() also uses -- replaceDocument() (JSON import
  "replace", "reset to seed") additionally resets history, since loading a
  different document shouldn't leave the old one undoable into it; undo/redo
  themselves must not (that's the very stack they're unwinding).
- TableGrid.vue: Strg/Cmd+Z undoes, Strg/Cmd+Y or +Shift+Z redoes, deferring
  to a cell editor's native undo while one is open (the existing `if
  (editing.value) return` guard already covers this).
- App.vue: a window-level fallback for when no table has focus, skipped
  whenever focus sits in any text field so their own native undo keeps
  working.
- AppToolbar.vue: ↶/↷ buttons, disabled via store.canUndo/canRedo.

Verified interactively against the dev server with Playwright: the toolbar
button enables the instant a change happens (not after the debounce);
separately-timed edits each undo as their own step; a multi-tick resize drag
undoes in one step back to the original width; Ctrl+Z, Ctrl+Y and the
toolbar buttons all work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:19:43 +02:00
wkhandClaude Sonnet 5 67b011637d Make the formula bar mouse-resizable and wrapping
Switches the formula bar from a single-line <input> to a <textarea>:

- A plain <textarea> gets a free, native mouse-draggable resize handle in
  its bottom-right corner (CSS resize: vertical) -- exactly the "erweiterbar/
  erhöhbar mit der Maus" ask, no custom drag code needed.
- Unlike the old <input>, it actually wraps a long formula instead of just
  scrolling it sideways, so dragging the handle taller has something to
  show (min-height keeps the collapsed case looking like before; max-height
  caps it at half the viewport).
- Brought its keyboard handling in line with the in-cell editor from the
  last two changes: Alt+Enter/Cmd+Enter insert a literal newline, F4 cycles
  every reference a selection overlaps, Escape reverts and blurs.
- .formula-bar switches to align-items: flex-start (with matching top
  margins on the labels) so the cell-name/fx labels stay pinned to the
  first line instead of vertically centering in a now-tall box.

Verified interactively: dragging the handle down 80px grows the bar from
~34px to ~113px; Alt+Enter inserts a real newline in the (now multi-line-
capable) field.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:11:01 +02:00
wkhandClaude Sonnet 5 8c9f3bbc87 Fix Alt+Enter newline not registering on Mac: add Cmd+Enter fallback
macOS's Option key is a compose key (used to type accented/special
characters), which can make it unreliable as a plain modifier in some
browser/layout combinations -- Alt+Enter alone wasn't reaching the handler
for this user on Mac. Microsoft's own Mac Excel shortcut for this is
inconsistent across versions too (Control+Option+Return traditionally,
plain Option+Return only in 365).

- onEditKeydown now accepts Enter with either altKey OR metaKey (Cmd),
  giving a Mac-safe alternative that doesn't depend on Option's compose
  behavior.
- Also checks e.code ('Enter'/'NumpadEnter') alongside e.key, in case
  composition ever alters what .key reports for the combination (.code
  reflects the physical key regardless).

Verified interactively: Alt+Enter, Cmd+Enter, and plain Enter (still
commits normally) all produce the expected value/behavior.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:08:21 +02:00
wkhandClaude Sonnet 5 cc76ea547f Auto-sizing cell editor, multi-ref F4, and Alt+Enter line breaks
1. In-cell editor width/height ("nur in der Breite der Zelle angezeigt"):
   the editor now lives as an overlay (like the clip/fill markers) instead
   of inside its <td>, so it can grow past the cell -- width first, up to
   an approximated display-width cap, then wrapping and growing height.
   Sized via a hidden mirror element (same font/padding, real DOM
   measurement -- a <textarea> can't self-size to content with CSS alone).

2. F4 now cycles every reference a real text selection overlaps, each from
   its own lock state, not just the one at the caret -- select a stretch of
   formula spanning several references and F4 adjusts all of them at once,
   same as Excel. cycleReferenceLock(formula, caret) -> cycleReferenceLocks
   (formula, start, end); a collapsed selection (start === end) keeps the
   old single-reference caret behaviour. Wired into both the in-cell editor
   and the formula bar.

3. Alt+Enter inserts a literal newline in the editor instead of committing
   (explicit splice at the caret, not relying on the textarea's native
   Enter handling, which isn't consistently reachable for Alt+Enter). A
   cell holding a newline displays it wrapped (.cell.multiline) instead of
   collapsing to one ellipsised line.

Along the way: a <td>'s own max-height doesn't cap table-row growth in any
browser (a table-layout quirk) -- a wrapped multi-line cell was silently
growing its row past rowH(r), which would have desynced every pixel
position computed from rowH()/rowTop() (the overlays, sticky frozen panes,
the row-resize handle). Fixed by moving cell text into its own block-level
.cell-content child, which *does* respect max-height + overflow:hidden.

Verified interactively against the dev server with Playwright: a long
formula gets a ~300px editor instead of the 104px cell; Alt+Enter produces
a real "\n" that survives commit and displays wrapped, with the row height
unchanged (26px, not the ~40px it grew to before the .cell-content fix);
F4 on a full-formula selection turns every reference absolute in one press
and keeps cycling all of them on repeated presses; existing selection,
copy/paste and fill-handle behaviour all regression-checked clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 11:04:45 +02:00
wkhandClaude Sonnet 5 830dcb8eeb Add the fill handle: drag the selection's corner to copy/fill, Excel-style
- store: fillSelection(tableId, source, target) repeats a source rectangle's
  cells (values, formulas -- reference-adjusted via the same shiftFormula()
  a copy uses -- and formatting) across a target rectangle. target may
  extend the source in any single direction (up/down/left/right); a source
  bigger than one row/column cycles through modulo its own size, so it
  still fills correctly past a non-exact-multiple trailing repetition.
- TableGrid.vue: replaces the old decorative per-active-cell square with a
  proper fill handle -- a small square overlaid at the bottom-right corner
  of the *selection* (not just the anchor cell), draggable. Dragging shows
  a live dashed preview of the cells about to be filled (snapping to
  whichever axis -- vertical or horizontal -- is dragged furthest along,
  same as Excel), and on mouseup commits via fillSelection() and grows the
  selection to source + filled area, keeping the original anchor active.

Verified interactively against the dev server with Playwright: a formula
(=A1*2) filled down four rows lands as =A2*2/=A3*2/=A4*2 with correct
values, and a two-row source dragged right/down six cells cycles its
pattern correctly (1,2,1,2,1,2, including the trailing partial cycle).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 10:43:54 +02:00
wkhandClaude Sonnet 5 fafb118db6 Keep marking the copy source's first cell after the selection moves on
Copying a range showed the dashed marquee around the whole source, but the
distinct border on its first (anchor) cell disappeared the moment you
selected something else -- that border was drawn from the *live* selection's
anchor, which had just moved to the new (paste-destination) cell.

- ClipboardData now also stores the copy/cut selection's own anchor -- the
  cell the drag/shift-selection actually started from, which isn't always
  rect's top-left corner (a drag can start at any corner).
- clipboardRect exposes it; TableGrid renders a second small overlay,
  .clip-anchor, positioned on just that one cell -- independent of the
  current selection, so it stays visible for as long as the marquee does.

Verified interactively: copy B2:B5, click a cell elsewhere -- the marquee
and the solid border on B2 both remain, while the new cell gets its own
live-selection border.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 10:35:35 +02:00
wkhandClaude Sonnet 5 47afe8fc20 Fix copy marquee (one rectangle, not per-cell) and arrow-key collapse point
- Copy source marker: was outline:dashed on every td in the copied range,
  so each cell got its own little dashed box. Replaced with one absolutely
  positioned .clip-marquee overlay sized/positioned from the range's pixel
  bounds (colLeft/rowTop), giving a single dashed rectangle around the whole
  range, same as Excel/Numbers.
- Arrow keys (no Shift) after selecting a multi-cell range collapsed beside
  the *focus* (wherever the drag/shift-selection ended), not the *anchor*
  (the active, first-selected cell) -- inconsistent with the anchor-stays-
  active fix from the previous change and with Excel/Numbers, where a plain
  arrow key always steps from the active cell. navigate()/jumpTo()/Home/End
  now step from the anchor when collapsing (ext=false) and keep stepping
  from the focus when extending (Shift/Ctrl+Shift), which is unchanged.

Verified interactively against the dev server with Playwright (isolated
empty sheet, to sidestep the seed data's cascaded/overlapping tables):
selecting B2:B5 keeps the active-cell border on B2, copying shows exactly
one .clip-marquee, and ArrowLeft afterwards lands on A2, not A5.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 10:23:06 +02:00
wkhandClaude Sonnet 5 8e43cc294c Support $-locked references: relative, mixed and absolute
The engine already anticipated $A1/A$1/$A$1 in its grammar comment, but the
lexer had no rule for '$' at all -- any formula using it failed to parse.

- lexer.ts: '$' is now a valid word character, so "$A$1" tokenizes as one
  IDENT, same as any other bare word (the lexer still doesn't interpret it,
  per its "dumb lexer" design -- the parser/evaluator stay untouched, since
  evaluation is purely positional and $ has no effect on a value).
- refs.ts: parseA1()/toA1() now carry colAbs/rowAbs through the $ markers
  (backward compatible -- existing {col,row} destructuring is unaffected).
  Added cycleReferenceLock() for Excel/Numbers-style F4: relative ->
  absolute -> row-locked -> col-locked -> relative, toggling whichever
  reference sits at the caret (and the correct side of an A1:B2 range).
- document.ts: shiftFormula() now honors the lock on copy (a locked axis
  stays exactly as typed) but not on cut (a moved cell's reference follows
  it regardless of $, since cut relocates the cell itself rather than
  filling a relative offset).
- TableGrid.vue's inline cell editor and FormulaBar.vue both wire up F4 via
  the shared cycleReferenceLock() helper.
- Tests: lexer/parser coverage for $ tokenizing/parsing, a new refs.spec.ts
  for parseA1/toA1 round-tripping and the F4 cycle (incl. range side-
  picking), and document.spec.ts cases for locked-axis copy vs. cut.
- README.md / src/engine/README.md: documented the $ semantics, F4, and
  corrected a couple of now-stale claims (test count, "$ is ignored, there's
  no copy/fill", "active cell is always the last-clicked one").

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 10:11:10 +02:00
wkhandClaude Sonnet 5 e45cdfd077 Keep the first-selected cell marked as active during a multi-cell selection
The active cell (bold border/handle, formula bar target) used to track
selection.focus, so it followed the mouse/cursor while dragging or
shift-extending a range. Switch it to selection.anchor -- the first cell of
the current selection action -- so it stays put while the range grows,
matching Excel/Numbers. Tab/Enter data-entry flow already keeps anchor ==
focus while it moves the cursor, so that behaviour is unaffected.

- document.ts: activeCell/activeCellName/activeCellInput read the anchor.
- FormulaBar.vue: commits to the anchor cell to match what it displays.
- TableGrid.vue: isActive() (border + handle) and F2/type-to-edit target
  the anchor; Home/End keep using the cursor's row via s.focus.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 09:59:29 +02:00
wkhandClaude Sonnet 5 44f3314137 Add cell selection copy/cut/paste with formula reference adjustment
- store: copySelection/cutSelection/pasteClipboard/cancelClipboard hold an
  in-memory (unpersisted) clipboard buffer of raw cell inputs + formatting.
- shiftFormula rewrites every relative cell reference in a copied/cut formula
  by the move delta (the engine has no $-absolute refs, so all refs qualify).
  Copy shifts every reference uniformly; cut only follows references that
  point inside the moved block itself, leaving refs to untouched cells alone
  and never touching a qualified Table!A1 reference.
- paste anchors on a smaller/single-cell selection (and overflows past it),
  or tiles across a selection that's an exact larger multiple of the buffer.
- TableGrid: Strg+C/X/V shortcuts, context-menu entries, a dashed "clip-src"
  source marker, and Escape drops a pending buffer.
- document.spec.ts: coverage for reference shifting, tiled/fill paste, cut's
  inside/outside reference split, format copy, and Escape cancelling.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 09:55:09 +02:00
wkh 31108977b9 - before copy paste 2026-09-02 09:45:44 +02:00
wkh c7885067e7 - init 2026-08-29 17:47:12 +02:00