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,167 @@
|
||||
import { isError, serialToUtcDate, type CellValue } from '../engine';
|
||||
import type { CellNumFmt } from './types';
|
||||
|
||||
/**
|
||||
* Locale used to render numeric values. `'plain'` is not a real locale -- it
|
||||
* renders the bare JS number (`.` decimal, no grouping).
|
||||
*
|
||||
* A *display* setting only. `rawString()` (CSV / XLSX export) stays
|
||||
* locale-neutral so round-trips are lossless.
|
||||
*/
|
||||
export type NumberLocale =
|
||||
| 'plain'
|
||||
| 'de-DE'
|
||||
| 'de-CH'
|
||||
| 'en-US'
|
||||
| 'en-GB'
|
||||
| 'fr-FR'
|
||||
| 'it-IT';
|
||||
|
||||
export const NUMBER_LOCALES: ReadonlyArray<{ id: NumberLocale; label: string }> = [
|
||||
{ id: 'plain', label: 'Ohne Trennzeichen · 1234.5' },
|
||||
{ id: 'de-DE', label: 'Deutsch (DE) · 1.234,5' },
|
||||
{ id: 'de-CH', label: 'Schweiz (CH) · 1’234.5' },
|
||||
{ id: 'en-US', label: 'English (US) · 1,234.5' },
|
||||
{ id: 'en-GB', label: 'English (UK) · 1,234.5' },
|
||||
{ id: 'fr-FR', label: 'Français (FR) · 1 234,5' },
|
||||
{ id: 'it-IT', label: 'Italiano (IT) · 1.234,5' },
|
||||
];
|
||||
|
||||
const LOCALE_IDS = new Set(NUMBER_LOCALES.map((l) => l.id));
|
||||
export function isNumberLocale(x: unknown): x is NumberLocale {
|
||||
return typeof x === 'string' && LOCALE_IDS.has(x as NumberLocale);
|
||||
}
|
||||
export const DEFAULT_LOCALE: NumberLocale = 'de-DE';
|
||||
|
||||
/** Cell number-format options offered in the UI. */
|
||||
export const NUM_FORMATS: ReadonlyArray<{ id: CellNumFmt; label: string }> = [
|
||||
{ id: 'auto', label: 'Automatisch' },
|
||||
{ id: 'plain', label: 'Ohne Formatierung' },
|
||||
{ id: 'int', label: 'Ganzzahl · 1.235' },
|
||||
{ id: 'dec1', label: '1 Nachkommastelle · 1.234,6' },
|
||||
{ id: 'dec2', label: '2 Nachkommastellen · 1.234,56' },
|
||||
{ id: 'pct0', label: 'Prozent · 12 %' },
|
||||
{ id: 'pct2', label: 'Prozent · 12,34 %' },
|
||||
{ id: 'date', label: 'Datum · 02.09.2026' },
|
||||
{ id: 'dateiso', label: 'Datum (ISO) · 2026-09-02' },
|
||||
{ id: 'time', label: 'Zeit · 14:30' },
|
||||
{ id: 'datetime', label: 'Datum + Zeit · 02.09.2026 14:30' },
|
||||
{ id: 'duration', label: 'Dauer · 37:30' },
|
||||
];
|
||||
const NUM_FMT_IDS = new Set(NUM_FORMATS.map((f) => f.id));
|
||||
export function isCellNumFmt(x: unknown): x is CellNumFmt {
|
||||
return typeof x === 'string' && NUM_FMT_IDS.has(x as CellNumFmt);
|
||||
}
|
||||
|
||||
const round10 = (n: number) => Math.round(n * 1e10) / 1e10;
|
||||
|
||||
function nf(locale: NumberLocale, opts: Intl.NumberFormatOptions): Intl.NumberFormat {
|
||||
// for the pseudo-locale 'plain' use en-US digits but turn grouping off
|
||||
return new Intl.NumberFormat(locale === 'plain' ? 'en-US' : locale, {
|
||||
useGrouping: locale !== 'plain',
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, '0');
|
||||
|
||||
/** `YYYY-MM-DD`, hand-written rather than via `Intl` -- there's no locale
|
||||
* that reliably reproduces ISO 8601, and this is also what the `'plain'`
|
||||
* pseudo-locale uses for a date (an `Intl` call with an `undefined` locale
|
||||
* would silently fall back to the *browser's* locale instead of staying
|
||||
* neutral). Shared by the locale-independent `'dateiso'` cell format and
|
||||
* by `formatDatePart`'s `'plain'` branch below. */
|
||||
function isoDateString(d: Date): string {
|
||||
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
function formatDatePart(d: Date, locale: NumberLocale): string {
|
||||
if (locale === 'plain') return isoDateString(d);
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
}).format(d);
|
||||
}
|
||||
function formatTimePart(d: Date, locale: NumberLocale): string {
|
||||
if (locale === 'plain') return `${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}`;
|
||||
return new Intl.DateTimeFormat(locale, {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZone: 'UTC',
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/** Elapsed time as `[h]:mm` -- hours keep counting past 24 (`37:30`), the
|
||||
* way a sum of working times should read; seconds only when there are any.
|
||||
* Locale-independent: `:` is the time separator in every supported locale. */
|
||||
function formatDuration(n: number): string {
|
||||
let sec = Math.round(Math.abs(n) * 86400);
|
||||
const h = Math.floor(sec / 3600);
|
||||
sec -= h * 3600;
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec - m * 60;
|
||||
return `${n < 0 && (h || m || s) ? '-' : ''}${pad2(h)}:${pad2(m)}${s ? `:${pad2(s)}` : ''}`;
|
||||
}
|
||||
|
||||
/** Render a computed value for display (grid cell / status bar / chart). */
|
||||
export function formatValue(
|
||||
v: CellValue,
|
||||
locale: NumberLocale = DEFAULT_LOCALE,
|
||||
numFmt: CellNumFmt = 'auto',
|
||||
): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v !== 'number') return v;
|
||||
if (!Number.isFinite(v)) return '#NUM!';
|
||||
|
||||
const n = round10(v);
|
||||
switch (numFmt) {
|
||||
case 'plain':
|
||||
return String(n);
|
||||
case 'int':
|
||||
return nf(locale, { maximumFractionDigits: 0 }).format(n);
|
||||
case 'dec1':
|
||||
return nf(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(n);
|
||||
case 'dec2':
|
||||
return nf(locale, { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n);
|
||||
case 'pct0':
|
||||
return nf(locale, { style: 'percent', maximumFractionDigits: 0 }).format(n);
|
||||
case 'pct2':
|
||||
return nf(locale, {
|
||||
style: 'percent',
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(n);
|
||||
case 'date':
|
||||
return formatDatePart(serialToUtcDate(n), locale);
|
||||
case 'dateiso':
|
||||
return isoDateString(serialToUtcDate(n)); // same regardless of locale
|
||||
case 'time':
|
||||
return formatTimePart(serialToUtcDate(n), locale);
|
||||
case 'datetime':
|
||||
// a bare time of day (< 1 day) has no calendar date -- showing the
|
||||
// epoch "30.12.1899" in front of it would be noise
|
||||
if (n >= 0 && n < 1) return formatTimePart(serialToUtcDate(n), locale);
|
||||
return `${formatDatePart(serialToUtcDate(n), locale)} ${formatTimePart(serialToUtcDate(n), locale)}`;
|
||||
case 'duration':
|
||||
return formatDuration(n);
|
||||
default: // 'auto'
|
||||
return locale === 'plain'
|
||||
? String(n)
|
||||
: nf(locale, { maximumFractionDigits: 10 }).format(n);
|
||||
}
|
||||
}
|
||||
|
||||
/** Plain (ungrouped, `.`-decimal) string -- machine-readable, for CSV / XLSX. */
|
||||
export function rawString(v: CellValue): string {
|
||||
if (v === null) return '';
|
||||
if (isError(v)) return v.code;
|
||||
if (typeof v === 'number') return String(round10(v));
|
||||
return v;
|
||||
}
|
||||
|
||||
export function isNumeric(v: CellValue): boolean {
|
||||
return typeof v === 'number' && Number.isFinite(v);
|
||||
}
|
||||
Reference in New Issue
Block a user