Add Rails backend: accounts via magic link, server documents, sharing

The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-28 20:09:06 +02:00
co-authored by Claude Opus 5.5
parent 5a0f1fcd78
commit e14bada114
190 changed files with 6077 additions and 722 deletions
@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import { FUNCTIONS } from '../functions';
import { dateToSerial, timeSerial } from '../dates';
import { isError, type CellValue } from '../types';
const call = (name: string, ...args: CellValue[]) => FUNCTIONS[name](args);
const d = (y: number, m: number, day: number) => dateToSerial(y, m, day);
describe('date parts', () => {
const t = dateToSerial(2026, 9, 2, 14, 30, 15);
it('YEAR / MONTH / DAY / HOUR / MINUTE / SECOND', () => {
expect(call('YEAR', t)).toBe(2026);
expect(call('MONTH', t)).toBe(9);
expect(call('DAY', t)).toBe(2);
expect(call('HOUR', t)).toBe(14);
expect(call('MINUTE', t)).toBe(30);
expect(call('SECOND', t)).toBe(15);
});
it('rounds to the whole second, so float noise never shows as :59', () => {
const noisy = timeSerial(14, 29, 59) + 1 / 86400;
expect(call('HOUR', noisy)).toBe(14);
expect(call('MINUTE', noisy)).toBe(30);
expect(call('SECOND', noisy)).toBe(0);
});
it('checks argument count and type', () => {
expect(isError(call('YEAR'))).toBe(true);
expect(isError(call('YEAR', 1, 2))).toBe(true);
expect(isError(call('YEAR', 'kein Datum'))).toBe(true);
});
});
describe('WEEKDAY (ISO default: Monday = 1)', () => {
const wed = d(2026, 9, 2);
it('defaults to ISO numbering', () => {
expect(call('WEEKDAY', wed)).toBe(3);
expect(call('WEEKDAY', d(2026, 9, 6))).toBe(7); // Sunday
});
it('supports Excel types 1, 2, 3', () => {
expect(call('WEEKDAY', wed, 1)).toBe(4); // Sunday = 1
expect(call('WEEKDAY', wed, 2)).toBe(3);
expect(call('WEEKDAY', wed, 3)).toBe(2); // Monday = 0
expect(isError(call('WEEKDAY', wed, 7))).toBe(true);
});
});
describe('ISO week numbers', () => {
it('handles the year boundaries', () => {
expect(call('ISOWEEKNUM', d(2026, 1, 1))).toBe(1); // Thursday
expect(call('ISOWEEKNUM', d(2027, 1, 1))).toBe(53); // Friday -> last week of 2026
expect(call('ISOWEEKNUM', d(2021, 1, 3))).toBe(53); // Sunday -> week 53 of 2020
expect(call('ISOWEEKNUM', d(2024, 12, 30))).toBe(1); // Monday -> week 1 of 2025
expect(call('ISOWEEKNUM', d(2026, 9, 2))).toBe(36);
});
it('WEEKNUM is ISO by default, US-style with type 1/2', () => {
expect(call('WEEKNUM', d(2021, 1, 3))).toBe(53);
expect(call('WEEKNUM', d(2026, 1, 3), 1)).toBe(1); // Saturday, same week as 1 Jan
expect(call('WEEKNUM', d(2026, 1, 4), 1)).toBe(2); // Sunday starts week 2
expect(call('WEEKNUM', d(2026, 1, 4), 2)).toBe(1); // ... but not when weeks start Monday
expect(call('WEEKNUM', d(2026, 1, 5), 2)).toBe(2);
});
});
describe('month arithmetic', () => {
it('EDATE clamps to the end of a shorter month', () => {
expect(call('EDATE', d(2026, 1, 31), 1)).toBe(d(2026, 2, 28));
expect(call('EDATE', d(2024, 1, 31), 1)).toBe(d(2024, 2, 29));
expect(call('EDATE', d(2026, 3, 31), -1)).toBe(d(2026, 2, 28));
expect(call('EDATE', d(2026, 9, 2), 12)).toBe(d(2027, 9, 2));
});
it('EOMONTH', () => {
expect(call('EOMONTH', d(2026, 9, 2), 0)).toBe(d(2026, 9, 30));
expect(call('EOMONTH', d(2026, 9, 2), -1)).toBe(d(2026, 8, 31));
expect(call('EOMONTH', d(2026, 1, 15), 1)).toBe(d(2026, 2, 28));
});
});
describe('differences', () => {
it('DAYS counts whole calendar days, ignoring the time', () => {
expect(call('DAYS', d(2026, 12, 31), d(2026, 1, 1))).toBe(364);
expect(call('DAYS', d(2026, 1, 2) + 0.9, d(2026, 1, 1) + 0.1)).toBe(1);
});
it('DATEDIF in every unit', () => {
const s = d(2020, 2, 29);
const e = d(2026, 9, 2);
expect(call('DATEDIF', s, e, 'Y')).toBe(6);
expect(call('DATEDIF', s, e, 'M')).toBe(78);
expect(call('DATEDIF', s, e, 'D')).toBe(e - s);
expect(call('DATEDIF', s, e, 'YM')).toBe(6);
expect(call('DATEDIF', s, e, 'MD')).toBe(4); // 29.08. -> 02.09.
expect(call('DATEDIF', s, e, 'yd')).toBe(d(2026, 9, 2) - d(2026, 2, 28));
expect(isError(call('DATEDIF', e, s, 'D'))).toBe(true);
expect(isError(call('DATEDIF', s, e, 'W'))).toBe(true);
});
});
describe('working days (Monday-Friday)', () => {
it('NETWORKDAYS counts both ends and skips holidays on weekdays', () => {
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30))).toBe(22);
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 15))).toBe(21);
expect(call('NETWORKDAYS', d(2026, 9, 1), d(2026, 9, 30), d(2026, 9, 5))).toBe(22); // Saturday
expect(call('NETWORKDAYS', d(2026, 9, 30), d(2026, 9, 1))).toBe(-22);
});
it('WORKDAY steps over weekends and holidays, both directions', () => {
expect(call('WORKDAY', d(2026, 9, 4), 1)).toBe(d(2026, 9, 7)); // Fri -> Mon
expect(call('WORKDAY', d(2026, 9, 7), -1)).toBe(d(2026, 9, 4));
expect(call('WORKDAY', d(2026, 9, 4), 1, d(2026, 9, 7))).toBe(d(2026, 9, 8));
expect(call('WORKDAY', d(2026, 9, 2), 0)).toBe(d(2026, 9, 2));
});
});
describe('DATEVALUE / TIMEVALUE', () => {
it('read the same formats a cell entry is recognized in', () => {
expect(call('DATEVALUE', '2026-09-02')).toBe(d(2026, 9, 2));
expect(call('DATEVALUE', '02.09.2026 14:30')).toBe(d(2026, 9, 2));
expect(call('TIMEVALUE', '14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
expect(call('TIMEVALUE', '2026-09-02 14:30')).toBeCloseTo(timeSerial(14, 30, 0), 12);
});
it('pass a number through and reject other text', () => {
expect(call('DATEVALUE', d(2026, 9, 2) + 0.5)).toBe(d(2026, 9, 2));
expect(isError(call('DATEVALUE', 'hallo'))).toBe(true);
});
});