From 803eef59a70df24387713d7a191ac04344baf0e1 Mon Sep 17 00:00:00 2001 From: Alon Tuval Date: Fri, 12 Jun 2026 15:18:12 +0300 Subject: [PATCH] feat(display): user-controllable density (row height, event size, columns) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Row height varies with how many multi-day events stack; sparse rows still had a fixed 80px floor. Add a 'גודל' control in the view-settings group: a popover with 3 presets (דחוס / רגיל / מרווח) plus advanced sliders for row height, event bar height, event font, and column width. Values are pushed as CSS variables on :root and persisted to localStorage; the stylesheet keeps the current values as fallbacks (so the default look is unchanged). A comfort floor prevents extreme squeezing. Pure logic in src/lib/density.ts with tests (81 total). Co-Authored-By: Claude Opus 4.8 --- src/components/CalendarHeader.tsx | 2 + src/components/DensityControl.module.css | 128 ++++++++++++++++++++ src/components/DensityControl.tsx | 142 +++++++++++++++++++++++ src/components/LinearCalendar.module.css | 12 +- src/components/LinearCalendar.tsx | 2 +- src/lib/density.test.ts | 87 ++++++++++++++ src/lib/density.ts | 95 +++++++++++++++ 7 files changed, 461 insertions(+), 7 deletions(-) create mode 100644 src/components/DensityControl.module.css create mode 100644 src/components/DensityControl.tsx create mode 100644 src/lib/density.test.ts create mode 100644 src/lib/density.ts diff --git a/src/components/CalendarHeader.tsx b/src/components/CalendarHeader.tsx index 2d54bd6..f091dbb 100644 --- a/src/components/CalendarHeader.tsx +++ b/src/components/CalendarHeader.tsx @@ -10,6 +10,7 @@ import styles from './CalendarHeader.module.css'; registerLocale('he', he); import { CalendarListEntry } from '@/lib/google-calendar'; import CalendarFilter from './CalendarFilter'; +import DensityControl from './DensityControl'; import { signOut } from 'next-auth/react'; interface CalendarHeaderProps { @@ -280,6 +281,7 @@ export default function CalendarHeader({ > מפרידים + + + {open && ( +
+
גודל וצפיפות
+ +
+ {(Object.keys(DENSITY_PRESETS) as DensityPresetName[]).map(name => ( + + ))} +
+ +
+ update({ ...settings, cellMinH: v })} /> + update({ ...settings, barH: v })} /> + update({ ...settings, barFont: v })} /> + update({ ...settings, colMinW: v })} /> +
+ + +
+ )} + + ); +} diff --git a/src/components/LinearCalendar.module.css b/src/components/LinearCalendar.module.css index 974f361..ab2025a 100644 --- a/src/components/LinearCalendar.module.css +++ b/src/components/LinearCalendar.module.css @@ -18,7 +18,7 @@ .mainGrid { display: grid; /* 1 Month Label Col + 37 Cols */ - grid-template-columns: 100px repeat(37, minmax(40px, 1fr)); + grid-template-columns: 100px repeat(37, minmax(var(--col-min-w, 40px), 1fr)); gap: 24px 4px; min-width: 1500px; /* Force scroll for large grids */ @@ -67,7 +67,7 @@ .dayCell { border: 1px solid var(--border-color); border-radius: 6px; - min-height: 80px; + min-height: var(--cell-min-h, 80px); position: relative; background-color: var(--bg-card); transition: all 0.2s; @@ -133,7 +133,7 @@ } .dayNumber { - font-size: 0.85rem; + font-size: var(--day-num-font, 0.85rem); font-weight: 700; color: var(--cal-text); line-height: 1; @@ -210,12 +210,12 @@ } .emptyTrack { - height: 18px; + height: var(--bar-h, 18px); width: 100%; } .multiDayBar { - height: 18px; + height: var(--bar-h, 18px); margin-inline: -3px; background-color: var(--event-bar-bg, #3d7eff); position: relative; @@ -251,7 +251,7 @@ .barLabel { padding: 0 4px; - font-size: clamp(8px, 15cqi, 11px); + font-size: clamp(8px, 15cqi, var(--bar-font, 11px)); font-weight: 500; white-space: nowrap; color: inherit; diff --git a/src/components/LinearCalendar.tsx b/src/components/LinearCalendar.tsx index b61ba6f..ca33c3c 100644 --- a/src/components/LinearCalendar.tsx +++ b/src/components/LinearCalendar.tsx @@ -901,7 +901,7 @@ export default function LinearCalendar({ events: initialEventsProp, startDate, e
); } diff --git a/src/lib/density.test.ts b/src/lib/density.test.ts new file mode 100644 index 0000000..47f99c9 --- /dev/null +++ b/src/lib/density.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect } from 'vitest'; +import { + DENSITY_PRESETS, + DEFAULT_DENSITY, + DENSITY_BOUNDS, + clampDensity, + densityToCssVars, + matchPreset, + parseDensity, +} from './density'; + +describe('density presets', () => { + it('comfortable is the default and reproduces the current grid values', () => { + expect(DEFAULT_DENSITY).toBe(DENSITY_PRESETS.comfortable); + expect(DENSITY_PRESETS.comfortable).toEqual({ + cellMinH: 80, barH: 18, barFont: 11, dayNumFont: 14, colMinW: 40, + }); + }); + + it('compact is smaller and spacious is larger across the board', () => { + const { compact, comfortable, spacious } = DENSITY_PRESETS; + for (const k of ['cellMinH', 'barH', 'barFont', 'dayNumFont', 'colMinW'] as const) { + expect(compact[k]).toBeLessThan(comfortable[k]); + expect(spacious[k]).toBeGreaterThan(comfortable[k]); + } + }); + + it('every preset is within bounds (comfort floor respected)', () => { + for (const s of Object.values(DENSITY_PRESETS)) { + expect(s.cellMinH).toBeGreaterThanOrEqual(DENSITY_BOUNDS.cellMinH.min); + expect(s.barH).toBeGreaterThanOrEqual(DENSITY_BOUNDS.barH.min); + } + }); +}); + +describe('clampDensity', () => { + it('enforces the comfort floor (no aggressive compression)', () => { + const c = clampDensity({ cellMinH: 10, barH: 4, barFont: 2, dayNumFont: 2, colMinW: 5 }); + expect(c.cellMinH).toBe(DENSITY_BOUNDS.cellMinH.min); // 48 + expect(c.barH).toBe(DENSITY_BOUNDS.barH.min); + }); + it('enforces the ceiling and rounds', () => { + const c = clampDensity({ cellMinH: 999, barH: 999, barFont: 18.6, dayNumFont: 999, colMinW: 999 }); + expect(c.cellMinH).toBe(DENSITY_BOUNDS.cellMinH.max); + expect(c.barFont).toBe(15); // clamped to max, rounded + }); +}); + +describe('densityToCssVars', () => { + it('maps each setting to its CSS custom property in px', () => { + expect(densityToCssVars(DENSITY_PRESETS.comfortable)).toEqual({ + '--cell-min-h': '80px', + '--bar-h': '18px', + '--bar-font': '11px', + '--day-num-font': '14px', + '--col-min-w': '40px', + }); + }); +}); + +describe('matchPreset', () => { + it('identifies an exact preset', () => { + expect(matchPreset(DENSITY_PRESETS.compact)).toBe('compact'); + expect(matchPreset(DENSITY_PRESETS.spacious)).toBe('spacious'); + }); + it('returns custom for a tweaked setting', () => { + expect(matchPreset({ ...DENSITY_PRESETS.comfortable, barH: 20 })).toBe('custom'); + }); +}); + +describe('parseDensity', () => { + it('returns null for empty/invalid input', () => { + expect(parseDensity(null)).toBeNull(); + expect(parseDensity('not json')).toBeNull(); + expect(parseDensity('123')).toBeNull(); + }); + it('merges partial saved settings over defaults and clamps', () => { + const s = parseDensity(JSON.stringify({ cellMinH: 60 })); + expect(s).not.toBeNull(); + expect(s!.cellMinH).toBe(60); + expect(s!.barH).toBe(DEFAULT_DENSITY.barH); // filled from default + }); + it('clamps out-of-range persisted values', () => { + const s = parseDensity(JSON.stringify({ cellMinH: 5 })); + expect(s!.cellMinH).toBe(DENSITY_BOUNDS.cellMinH.min); + }); +}); diff --git a/src/lib/density.ts b/src/lib/density.ts new file mode 100644 index 0000000..3a558c3 --- /dev/null +++ b/src/lib/density.ts @@ -0,0 +1,95 @@ +/** + * Display-density settings: user-controllable sizing for the linear calendar + * (row height, event bar height/font, day-number font, column width). + * + * The values are pushed to the DOM as CSS custom properties; the stylesheet + * reads them with `var(--x, )`, so when nothing is set the grid + * looks exactly as before. The "comfortable" preset reproduces those defaults. + */ + +export interface DensitySettings { + /** Day-cell minimum height (px). Controls how compact sparse rows get. */ + cellMinH: number; + /** Multi-day event bar height (px). Controls how tall busy rows get. */ + barH: number; + /** Event label font size (px) — the upper bound of the responsive clamp. */ + barFont: number; + /** Day-number font size (px). */ + dayNumFont: number; + /** Grid column minimum width (px) — horizontal density. */ + colMinW: number; +} + +export type DensityPresetName = 'compact' | 'comfortable' | 'spacious'; + +export const DENSITY_PRESETS: Record = { + compact: { cellMinH: 48, barH: 14, barFont: 9, dayNumFont: 12, colMinW: 34 }, + comfortable: { cellMinH: 80, barH: 18, barFont: 11, dayNumFont: 14, colMinW: 40 }, + spacious: { cellMinH: 112, barH: 22, barFont: 13, dayNumFont: 16, colMinW: 50 }, +}; + +export const DEFAULT_DENSITY: DensitySettings = DENSITY_PRESETS.comfortable; + +/** Allowed ranges. The lower bounds keep a comfortable floor (no extreme squeeze). */ +export const DENSITY_BOUNDS: Record = { + cellMinH: { min: 48, max: 140 }, + barH: { min: 14, max: 26 }, + barFont: { min: 9, max: 15 }, + dayNumFont: { min: 11, max: 18 }, + colMinW: { min: 34, max: 60 }, +}; + +function clampNum(v: number, min: number, max: number): number { + if (!Number.isFinite(v)) return min; + return Math.min(max, Math.max(min, Math.round(v))); +} + +export function clampDensity(s: DensitySettings): DensitySettings { + return { + cellMinH: clampNum(s.cellMinH, DENSITY_BOUNDS.cellMinH.min, DENSITY_BOUNDS.cellMinH.max), + barH: clampNum(s.barH, DENSITY_BOUNDS.barH.min, DENSITY_BOUNDS.barH.max), + barFont: clampNum(s.barFont, DENSITY_BOUNDS.barFont.min, DENSITY_BOUNDS.barFont.max), + dayNumFont: clampNum(s.dayNumFont, DENSITY_BOUNDS.dayNumFont.min, DENSITY_BOUNDS.dayNumFont.max), + colMinW: clampNum(s.colMinW, DENSITY_BOUNDS.colMinW.min, DENSITY_BOUNDS.colMinW.max), + }; +} + +/** Map settings to the CSS custom properties the stylesheet consumes. */ +export function densityToCssVars(s: DensitySettings): Record { + return { + '--cell-min-h': `${s.cellMinH}px`, + '--bar-h': `${s.barH}px`, + '--bar-font': `${s.barFont}px`, + '--day-num-font': `${s.dayNumFont}px`, + '--col-min-w': `${s.colMinW}px`, + }; +} + +/** Name of the preset that exactly matches `s`, or 'custom'. */ +export function matchPreset(s: DensitySettings): DensityPresetName | 'custom' { + for (const name of Object.keys(DENSITY_PRESETS) as DensityPresetName[]) { + const p = DENSITY_PRESETS[name]; + if ( + p.cellMinH === s.cellMinH && + p.barH === s.barH && + p.barFont === s.barFont && + p.dayNumFont === s.dayNumFont && + p.colMinW === s.colMinW + ) { + return name; + } + } + return 'custom'; +} + +/** Safely parse persisted settings (localStorage); returns null if unusable. */ +export function parseDensity(raw: string | null): DensitySettings | null { + if (!raw) return null; + try { + const parsed = JSON.parse(raw) as Partial; + if (typeof parsed !== 'object' || parsed === null) return null; + return clampDensity({ ...DEFAULT_DENSITY, ...parsed }); + } catch { + return null; + } +}