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:
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
dateToSerial,
|
||||
timeSerial,
|
||||
serialToUtcDate,
|
||||
todaySerial,
|
||||
parseDateTimeLiteral,
|
||||
} from '../dates';
|
||||
|
||||
describe('dateToSerial / serialToUtcDate', () => {
|
||||
it('epoch is 1899-12-30: serial 1 = 1899-12-31, serial 2 = 1900-01-01', () => {
|
||||
expect(dateToSerial(1899, 12, 31)).toBe(1);
|
||||
expect(dateToSerial(1900, 1, 1)).toBe(2);
|
||||
});
|
||||
|
||||
it('round-trips through serialToUtcDate', () => {
|
||||
const s = dateToSerial(2026, 9, 2);
|
||||
const d = serialToUtcDate(s);
|
||||
expect(d.getUTCFullYear()).toBe(2026);
|
||||
expect(d.getUTCMonth()).toBe(8); // 0-based
|
||||
expect(d.getUTCDate()).toBe(2);
|
||||
});
|
||||
|
||||
it('allows overflow, like Excel DATE() (month 13 -> next January)', () => {
|
||||
const s = dateToSerial(2026, 13, 1);
|
||||
const d = serialToUtcDate(s);
|
||||
expect(d.getUTCFullYear()).toBe(2027);
|
||||
expect(d.getUTCMonth()).toBe(0);
|
||||
expect(d.getUTCDate()).toBe(1);
|
||||
});
|
||||
|
||||
it('carries a fractional part for a time-of-day component', () => {
|
||||
const s = dateToSerial(2026, 9, 2, 12, 0, 0);
|
||||
expect(s % 1).toBeCloseTo(0.5, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeSerial', () => {
|
||||
it('noon = 0.5, midnight = 0', () => {
|
||||
expect(timeSerial(12, 0, 0)).toBeCloseTo(0.5, 10);
|
||||
expect(timeSerial(0, 0, 0)).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps past 24h like Excel TIME()', () => {
|
||||
expect(timeSerial(25, 0, 0)).toBeCloseTo(1 / 24, 10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('todaySerial', () => {
|
||||
it('matches the local calendar date', () => {
|
||||
const n = new Date();
|
||||
expect(todaySerial()).toBe(Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate())));
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseDateTimeLiteral', () => {
|
||||
it('recognizes ISO dates', () => {
|
||||
const r = parseDateTimeLiteral('2026-09-02');
|
||||
expect(r).toEqual({ kind: 'date', serial: dateToSerial(2026, 9, 2) });
|
||||
});
|
||||
|
||||
it('recognizes ISO datetimes (space or T separator, optional seconds)', () => {
|
||||
expect(parseDateTimeLiteral('2026-09-02 14:30')).toEqual({
|
||||
kind: 'datetime',
|
||||
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
|
||||
});
|
||||
expect(parseDateTimeLiteral('2026-09-02T14:30:15')).toEqual({
|
||||
kind: 'datetime',
|
||||
serial: dateToSerial(2026, 9, 2, 14, 30, 15),
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes dot dates, 4- and 2-digit years', () => {
|
||||
expect(parseDateTimeLiteral('02.09.2026')).toEqual({
|
||||
kind: 'date',
|
||||
serial: dateToSerial(2026, 9, 2),
|
||||
});
|
||||
expect(parseDateTimeLiteral('02.09.26')).toEqual({
|
||||
kind: 'date',
|
||||
serial: dateToSerial(2026, 9, 2),
|
||||
});
|
||||
expect(parseDateTimeLiteral('02.09.99')).toEqual({
|
||||
kind: 'date',
|
||||
serial: dateToSerial(1999, 9, 2),
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes dot datetimes', () => {
|
||||
expect(parseDateTimeLiteral('02.09.2026 14:30')).toEqual({
|
||||
kind: 'datetime',
|
||||
serial: dateToSerial(2026, 9, 2, 14, 30, 0),
|
||||
});
|
||||
});
|
||||
|
||||
it('recognizes plain times', () => {
|
||||
expect(parseDateTimeLiteral('14:30')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 0) });
|
||||
expect(parseDateTimeLiteral('14:30:15')).toEqual({ kind: 'time', serial: timeSerial(14, 30, 15) });
|
||||
});
|
||||
|
||||
it('does NOT recognize slash-separated dates (day-first/month-first ambiguity)', () => {
|
||||
expect(parseDateTimeLiteral('02/09/2026')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an out-of-range or rolled-over date instead of silently guessing', () => {
|
||||
expect(parseDateTimeLiteral('31.04.2026')).toBeNull(); // April has 30 days
|
||||
expect(parseDateTimeLiteral('30.02.2026')).toBeNull(); // no Feb 30
|
||||
expect(parseDateTimeLiteral('2026-13-01')).toBeNull(); // month 13
|
||||
});
|
||||
|
||||
it('rejects an out-of-range minute/second', () => {
|
||||
expect(parseDateTimeLiteral('12:60')).toBeNull();
|
||||
expect(parseDateTimeLiteral('37:30:60')).toBeNull();
|
||||
});
|
||||
|
||||
it('reads 24 hours or more as a duration, not a time of day', () => {
|
||||
expect(parseDateTimeLiteral('25:00')).toEqual({ kind: 'duration', serial: 25 / 24 });
|
||||
expect(parseDateTimeLiteral('37:30')).toEqual({ kind: 'duration', serial: 37.5 / 24 });
|
||||
expect(parseDateTimeLiteral('100:00:30')!.serial).toBeCloseTo((100 * 3600 + 30) / 86400, 12);
|
||||
expect(parseDateTimeLiteral('23:59')!.kind).toBe('time');
|
||||
});
|
||||
|
||||
it('does not misfire on plain numbers or arbitrary text', () => {
|
||||
expect(parseDateTimeLiteral('1234.5')).toBeNull();
|
||||
expect(parseDateTimeLiteral('hello')).toBeNull();
|
||||
expect(parseDateTimeLiteral('')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user