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,232 @@
|
||||
/**
|
||||
* Dates & times as plain numbers -- "serial days" since a fixed epoch,
|
||||
* exactly like every other spreadsheet (Excel, Google Sheets, Numbers): the
|
||||
* integer part is the calendar day, the fractional part is the time of day
|
||||
* (0 = midnight, 0.5 = noon). Epoch = 1899-12-30 (the Google Sheets / Excel
|
||||
* convention, WITHOUT Excel's historical 1900-leap-year bug), so serial 1 =
|
||||
* 1899-12-31, serial 2 = 1900-01-01. This is the whole reason date
|
||||
* arithmetic (`=faelligkeit - rechnungsdatum`, `=rechnungsdatum + 30`) just
|
||||
* works with the existing `+`/`-` operators -- no engine change needed
|
||||
* there at all.
|
||||
*
|
||||
* All arithmetic goes through UTC fields (`Date.UTC` / `getUTC*`) so a
|
||||
* serial round-trips exactly regardless of the browser's local timezone.
|
||||
*/
|
||||
|
||||
import { FormulaError, isError, type CellValue } from './types';
|
||||
|
||||
const EPOCH_MS = Date.UTC(1899, 11, 30);
|
||||
const MS_PER_DAY = 86_400_000;
|
||||
|
||||
/** `DATE()`'s building block -- plain `Date.UTC`, overflow allowed on
|
||||
* purpose (`dateToSerial(2026, 13, 1)` = 2027-01-01), matching Excel's
|
||||
* `DATE()`. Contrast with the stricter, overflow-rejecting builder used by
|
||||
* {@link parseDateTimeLiteral} below for *recognizing typed input*. */
|
||||
export function dateToSerial(y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number {
|
||||
return (Date.UTC(y, mo - 1, d, h, mi, s) - EPOCH_MS) / MS_PER_DAY;
|
||||
}
|
||||
|
||||
/** `TIME()`'s building block -- wraps like Excel's TIME (`TIME(25,0,0)` is
|
||||
* the 1:00 fraction of the *next* day, i.e. `0.041666...`). */
|
||||
export function timeSerial(h: number, mi: number, s: number): number {
|
||||
const total = (((h * 3600 + mi * 60 + s) % 86400) + 86400) % 86400;
|
||||
return total / 86400;
|
||||
}
|
||||
|
||||
/** Serial -> UTC `Date`, for display formatting. */
|
||||
export function serialToUtcDate(serial: number): Date {
|
||||
return new Date(EPOCH_MS + Math.round(serial * MS_PER_DAY));
|
||||
}
|
||||
|
||||
/** A serial's calendar and clock fields. The time is rounded to the whole
|
||||
* second first, so floating-point noise (`TIME(14;30;0)` summed from parts)
|
||||
* never turns 14:30:00 into 14:29:59. `day` is the integer serial of the
|
||||
* calendar day; `weekday` follows ISO 8601 (Monday = 1 ... Sunday = 7). */
|
||||
export interface SerialParts {
|
||||
day: number;
|
||||
year: number;
|
||||
month: number;
|
||||
date: number;
|
||||
weekday: number;
|
||||
hour: number;
|
||||
minute: number;
|
||||
second: number;
|
||||
}
|
||||
export function serialParts(serial: number): SerialParts {
|
||||
const totalSec = Math.round(serial * 86400);
|
||||
const day = Math.floor(totalSec / 86400);
|
||||
const sec = totalSec - day * 86400;
|
||||
const d = new Date(EPOCH_MS + day * MS_PER_DAY);
|
||||
return {
|
||||
day,
|
||||
year: d.getUTCFullYear(),
|
||||
month: d.getUTCMonth() + 1,
|
||||
date: d.getUTCDate(),
|
||||
weekday: ((d.getUTCDay() + 6) % 7) + 1,
|
||||
hour: Math.floor(sec / 3600),
|
||||
minute: Math.floor((sec % 3600) / 60),
|
||||
second: sec % 60,
|
||||
};
|
||||
}
|
||||
|
||||
/** ISO 8601 week number (weeks start Monday; week 1 is the one containing
|
||||
* the year's first Thursday) of a serial's calendar day. */
|
||||
export function isoWeekNumber(serial: number): number {
|
||||
const { day, weekday } = serialParts(serial);
|
||||
const thursday = day - weekday + 4; // the Thursday of this ISO week decides its year
|
||||
const { year } = serialParts(thursday);
|
||||
const jan1 = Math.round(dateToSerial(year, 1, 1));
|
||||
return Math.floor((thursday - jan1) / 7) + 1;
|
||||
}
|
||||
|
||||
/** Last calendar day of the month `months` after the serial's month. */
|
||||
export function endOfMonthSerial(serial: number, months: number): number {
|
||||
const { year, month } = serialParts(serial);
|
||||
return Math.round(dateToSerial(year, month + months + 1, 0));
|
||||
}
|
||||
|
||||
/** Same day `months` later, clamped to that month's last day (31.01. + 1
|
||||
* month = 28./29.02.), like Excel's EDATE. */
|
||||
export function addMonthsSerial(serial: number, months: number): number {
|
||||
const { year, month, date } = serialParts(serial);
|
||||
const last = serialParts(endOfMonthSerial(serial, months)).date;
|
||||
return Math.round(dateToSerial(year, month + months, Math.min(date, last)));
|
||||
}
|
||||
|
||||
/** Today's *local* calendar date (a date is a calendar concept, not a
|
||||
* timezone-relative instant), as an integer serial (no time-of-day part). */
|
||||
export function todaySerial(): number {
|
||||
const n = new Date();
|
||||
return Math.trunc(dateToSerial(n.getFullYear(), n.getMonth() + 1, n.getDate()));
|
||||
}
|
||||
|
||||
/** Local date + time, as a serial with a fractional part.
|
||||
*
|
||||
* Known limitation (documented in both READMEs): this engine recalculates
|
||||
* incrementally, only when a precedent cell changes -- there's no timer or
|
||||
* background work. A cell holding `=TODAY()`/`=NOW()` is NOT "volatile"
|
||||
* the way Excel's is: it refreshes the next time *that cell itself* is
|
||||
* re-edited or recalculated, not spontaneously when the clock ticks over. */
|
||||
export function nowSerial(): number {
|
||||
const n = new Date();
|
||||
return dateToSerial(
|
||||
n.getFullYear(),
|
||||
n.getMonth() + 1,
|
||||
n.getDate(),
|
||||
n.getHours(),
|
||||
n.getMinutes(),
|
||||
n.getSeconds() + n.getMilliseconds() / 1000,
|
||||
);
|
||||
}
|
||||
|
||||
/** `'duration'` is a time span typed with 24 or more hours (`37:30`) --
|
||||
* shown as elapsed hours instead of wrapping to a time of day. */
|
||||
export type DateLiteralKind = 'date' | 'time' | 'datetime' | 'duration';
|
||||
|
||||
const ISO_DATETIME_RE = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
||||
const ISO_DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
const DOT_DATETIME_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})[T ](\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
||||
const DOT_DATE_RE = /^(\d{1,2})\.(\d{1,2})\.(\d{2}|\d{4})$/;
|
||||
const TIME_RE = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
|
||||
const DURATION_RE = /^(\d{1,6}):(\d{2})(?::(\d{2}))?$/;
|
||||
|
||||
/** 2-digit year -> 4-digit, the usual spreadsheet convention: 00-29 -> 20xx,
|
||||
* 30-99 -> 19xx. */
|
||||
function expandYear(yStr: string): number {
|
||||
if (yStr.length === 4) return Number(yStr);
|
||||
const yy = Number(yStr);
|
||||
return yy <= 29 ? 2000 + yy : 1900 + yy;
|
||||
}
|
||||
|
||||
/** Strict date builder for *recognizing typed input* -- unlike `dateToSerial`
|
||||
* (used by the `DATE()` formula function), an out-of-range or rolled-over
|
||||
* component (`31.04.2026`, `30.02.2026`, hour `25`) means "this text isn't
|
||||
* actually a date" rather than "roll over to the next month": leaving it as
|
||||
* plain text beats silently guessing. Builds the candidate via `Date.UTC`,
|
||||
* then verifies it by reading the fields back. */
|
||||
function strictDateSerial(y: number, mo: number, d: number, h = 0, mi = 0, s = 0): number | null {
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31 || h > 23 || mi > 59 || s > 59) return null;
|
||||
const ms = Date.UTC(y, mo - 1, d, h, mi, s);
|
||||
const check = new Date(ms);
|
||||
if (check.getUTCFullYear() !== y || check.getUTCMonth() !== mo - 1 || check.getUTCDate() !== d) {
|
||||
return null;
|
||||
}
|
||||
return (ms - EPOCH_MS) / MS_PER_DAY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize a raw cell literal as a date, time, or date+time -- used both to
|
||||
* turn typed input into its serial number ({@link workbook.ts}'s
|
||||
* `literalValue`) and to decide whether to auto-apply a matching display
|
||||
* format on entry (`store/document.ts`'s `setCellInput`).
|
||||
*
|
||||
* Deliberately locale-independent and conservative: only ISO dates
|
||||
* (`2026-09-02`) and dot-separated day.month.year dates (`02.09.2026`,
|
||||
* `02.09.26`) are recognized, plus `HH:MM[:SS]` time (24+ hours: a
|
||||
* duration) and date + time combined
|
||||
* with a space or `T`. Slash-separated dates (`02/09/2026`) are
|
||||
* intentionally NOT recognized: day-first vs. month-first is ambiguous
|
||||
* across this app's supported locales (en-US is month-first, every other
|
||||
* supported locale is day-first) -- guessing wrong would silently corrupt
|
||||
* the entered date, which is worse than leaving it as plain text.
|
||||
*/
|
||||
export function parseDateTimeLiteral(
|
||||
raw: string,
|
||||
): { kind: DateLiteralKind; serial: number } | null {
|
||||
const t = raw.trim();
|
||||
|
||||
let m = ISO_DATETIME_RE.exec(t);
|
||||
if (m) {
|
||||
const serial = strictDateSerial(+m[1], +m[2], +m[3], +m[4], +m[5], m[6] ? +m[6] : 0);
|
||||
return serial === null ? null : { kind: 'datetime', serial };
|
||||
}
|
||||
m = ISO_DATE_RE.exec(t);
|
||||
if (m) {
|
||||
const serial = strictDateSerial(+m[1], +m[2], +m[3]);
|
||||
return serial === null ? null : { kind: 'date', serial };
|
||||
}
|
||||
m = DOT_DATETIME_RE.exec(t);
|
||||
if (m) {
|
||||
const serial = strictDateSerial(expandYear(m[3]), +m[2], +m[1], +m[4], +m[5], m[6] ? +m[6] : 0);
|
||||
return serial === null ? null : { kind: 'datetime', serial };
|
||||
}
|
||||
m = DOT_DATE_RE.exec(t);
|
||||
if (m) {
|
||||
const serial = strictDateSerial(expandYear(m[3]), +m[2], +m[1]);
|
||||
return serial === null ? null : { kind: 'date', serial };
|
||||
}
|
||||
m = TIME_RE.exec(t);
|
||||
if (m) {
|
||||
const h = +m[1];
|
||||
const mi = +m[2];
|
||||
const s = m[3] ? +m[3] : 0;
|
||||
if (mi > 59 || s > 59) return null;
|
||||
if (h <= 23) return { kind: 'time', serial: timeSerial(h, mi, s) };
|
||||
}
|
||||
// 24 hours or more can't be a time of day -- it's an elapsed time
|
||||
m = DURATION_RE.exec(t);
|
||||
if (m) {
|
||||
const h = +m[1];
|
||||
const mi = +m[2];
|
||||
const s = m[3] ? +m[3] : 0;
|
||||
if (mi > 59 || s > 59) return null;
|
||||
return { kind: 'duration', serial: (h * 3600 + mi * 60 + s) / 86400 };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Coerce a function argument to a number the way `evaluator.ts`'s
|
||||
* `toNumber()` does for arithmetic (null -> 0, numeric string cast,
|
||||
* anything else -> `#VALUE!`) -- duplicated here rather than imported
|
||||
* because `functions.ts` (and this module, which it uses) must not import
|
||||
* from `evaluator.ts` (evaluator.ts imports functions.ts, not the other
|
||||
* way around). Used by `DATE`/`TIME`'s argument handling in functions.ts. */
|
||||
export function strictNum(v: CellValue): number | FormulaError {
|
||||
if (isError(v)) return v;
|
||||
if (v === null) return 0;
|
||||
if (typeof v === 'number') return v;
|
||||
const trimmed = v.trim();
|
||||
if (trimmed === '') return 0;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : new FormulaError('#VALUE!', `'${v}' is not a number`);
|
||||
}
|
||||
Reference in New Issue
Block a user