diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index 3c143b54..3f8032a9 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -6390,3 +6390,60 @@ chance to run and it is cheap. Reconciliation for this cycle: 41 commits, 40 mapped, 1 correctly absent, run by bento-team-slides. Claude-Session: https://claude.ai/code/session_01Jcfdy8A69nonyATtm8vRy8 + +## 2026-09-09 — a calendar is ONE layout with two shapes, and its date is a rule + +bento/spaces gained a fifth view layout, `calendar`. Three choices in it are +the kind a later session would otherwise re-open and settle differently. + +**A month grid and a timeline are ONE entry in the layout cycle, not two.** +They are not peers of board/list/table/gallery. Those four answer four +different questions; these two answer one question — *when?* — at two +densities, and both densities are real in this app: journal entries are daily +and dense, a reading list's dates are sparse across years. A month grid is +useless on the second (thirty-six mostly-empty months to page through) and a +timeline cannot show the shape of a week. So both ship, behind ONE cycle entry, +with the choice on a second button that appears only while the calendar is on. + +The reason it is not six entries is that the layout control is a CYCLE, and a +cycle's cost is linear: every added shape is one more click for everybody who +did not want it, in both directions. The precedent for the alternative was +already in the file — `groupBy` is a board-only parameter with its own button, +hidden for every other shape — so this is the existing answer to "one shape, +one parameter" rather than a new mechanism. `span` is a STRING (`timeline`, +absent = month) and not a boolean, because `week` and `year` are the obvious +next two and a boolean cannot be widened afterwards. + +**Which date a page sits on is FIXED and stated, not configured.** The rule is: +`page.journal` when it is a real ISO date, else the first `date`-typed field in +schema order the page carries a real value for, else no date. A `dateBy` key on +the view would be a permanent format field bought to express a preference +nobody has asked for; the format's own rule is that every key ships forever +into files on other people's disks. What the UI owes instead is HONESTY, so the +view prints the rule above the grid. + +**A page with no date is SHOWN, in its own bucket.** This is the half that +would be tempting to skip. A calendar that silently holds fewer pages than the +count beside its own title is a view lying about what it contains — and the +pages it drops are precisely the ones somebody forgot to date, which is the +thing they most need to see. Same reasoning for a digit-shaped non-date +(`2026-13-99`): it is undated, never rolled forward into a real day it is not, +because every Date-based formatter will do that silently and confidently. + +**Dates are built from COMPONENTS and formatted through Intl, never parsed.** +`new Date('2026-01-01')` is UTC midnight by spec and is the previous day for +every reader west of Greenwich; journal.ts already carried this argument and +the calendar is where it bites hardest, because a whole grid shifts by one +column. The one place UTC is correct is subtracting two calendar dates, where +`Date.UTC` is what makes a day exactly a day across a daylight-saving boundary. +Month names, weekday names and the reader's FIRST DAY OF THE WEEK all come from +`Intl` — the last of those shifts the grid rather than relabelling it, so a +hand-written table gets the columns wrong in half the world as well as being +untranslatable (the extractor sweeps `t()` literals, so `t(MONTHS[m])` reaches +no catalog while the packer reports 100%). + +Measured in a built shell rather than asserted: February 2026 draws 28 cells in +four rendered rows in a Sunday-first locale and 35 in five in a Monday-first +one, August 2026 draws 42 in six, September 35 in five. The cell count is +derived from the month AND the reader; a fixed 35 silently loses the last days +of a six-week month, which is the classic failure of every calendar grid. diff --git a/docs/spaces-agents.md b/docs/spaces-agents.md index 8d151b43..7bb31ae2 100644 --- a/docs/spaces-agents.md +++ b/docs/spaces-agents.md @@ -79,7 +79,7 @@ unique ids the first time. | `pagelink` | `page` | a card linking to another page | | `link` | `url`, `title`, `desc`, `site`, `icon`, `image`, `html` | a card linking OUT of the space — see **Link cards** | | `prop` | `key`, `value`, `html` | one field value — see **The issue tracker** | -| `view` | `layout`, `groupBy`, `html` | a board or list of this space's issues | +| `view` | `layout`, `span`, `groupBy`, `html` | a board, list, table, gallery or calendar of this space's pages | `type` is a **string**, not a closed set: an unknown type survives a round trip and renders its `html` as a fallback. Properties are **flat on the block** — @@ -266,6 +266,22 @@ list**: `{ "type": "view", "layout": "board", "groupBy": "status", "html": "Issues by status" }`. Put it on a page of its own — a page carrying a view is laid out wide. +`layout` is one of `list`, `table`, `gallery`, `calendar` — **or absent, which +means a board.** Never write `"layout": "board"`: absence is what every view +written before layouts existed carries, and a stored `"board"` is a byte +difference that says nothing. + +A **calendar** lays the view's pages out by date, and has two shapes: a month +grid (`span` absent) and a chronological timeline (`span: "timeline"`, newest +first). Which date a page sits on is a **fixed rule, not a setting** — its +`journal` date if it has one, otherwise the first `date`-typed field in the +schema it carries a real `YYYY-MM-DD` value for. A page the rule finds no date +for is listed under "No date" rather than dropped, and a value that is +digit-shaped but not a real day (`2026-13-99`) counts as no date rather than +being rolled into some other day. Month names, weekday names and the first day +of the week come from the reader's locale at display time; nothing formatted is +ever stored. + **Not in this format, deliberately**: teams, per-user permissions, notifications, automation. The file is the team boundary and the capability. diff --git a/scripts/test-spaces-model.ts b/scripts/test-spaces-model.ts index a61883d3..b33c89a0 100644 --- a/scripts/test-spaces-model.ts +++ b/scripts/test-spaces-model.ts @@ -43,6 +43,11 @@ import { sortRows, unknownSortKeys, sortDirOf, cycleSort, type IssueRow, VIEW_LAYOUTS, layoutOf, nextLayout, } from '../spaces/src/fields.ts' +import { + CAL_SPANS, spanOf, nextSpan, dateOf, dateFieldsOf, splitByDate, dateHint, + monthGrid, monthOf, monthLabel, stepMonth, daysApart, defaultMonth, + firstWeekday, weekdayNames, timelineDays, +} from '../spaces/src/calendar.ts' import { inlineHtml, parseNote, planImport } from '../spaces/src/markdown.ts' import { canonicalMarks, applyMark, clearMarks, markActive, linkAt, linkAttrs, htmlToMd, @@ -573,8 +578,12 @@ for (const [label, input, err] of [ .split('.sp-view-tablewrap')[1]?.slice(0, 120) ?? ''), '…and scrolls inside itself, so a wide table never scrolls the page sideways') - // Cycling all the way round must leave the block as it started. The cycle - // ends at GALLERY now, so it is the last shape that clears the key. + // Cycling all the way round must leave the block as it started. Asked of the + // LAST shape rather than of a shape named here: the cycle has grown twice + // (gallery, then calendar) and both times this line was the thing that had to + // be edited to say a different word. Reading the last entry off the tuple + // asks the question that actually matters — whatever ends the cycle clears + // the key — and keeps asking it at the sixth shape. // // Asked of the CYCLE, not of the source. This assertion used to read // /gallery: undefined/ against editor.ts, which is a test of how the line is @@ -583,7 +592,8 @@ for (const [label, input, err] of [ // it green. So the shape question goes to nextLayout, and the writer question // goes to the writer's own body — not to the whole file, on the `coverFn` // precedent below, because `undefined` appears hundreds of times in editor.ts. - ok(nextLayout('gallery') === 'board', 'the shape after the last one is the board again') + ok(nextLayout(VIEW_LAYOUTS[VIEW_LAYOUTS.length - 1]) === 'board', + 'the shape after the last one is the board again') const toggleFn = ed.slice(ed.indexOf('private toggleViewLayout'), ed.indexOf('private openViewGroup')) ok(toggleFn.length > 0 && /'layout',\s*to === 'board' \? undefined :/.test(toggleFn), @@ -658,7 +668,7 @@ for (const [label, input, err] of [ const ed2 = fs.readFileSync(new URL('../spaces/src/editor.ts', import.meta.url), 'utf8') const props2 = fs.readFileSync(new URL('../spaces/src/props.ts', import.meta.url), 'utf8') ok(/layout === 'gallery'/.test(render), 'a view can be a gallery') - ok(nextLayout('table') === 'gallery' && nextLayout('gallery') === 'board', + ok(nextLayout('table') === 'gallery' && VIEW_LAYOUTS.includes('gallery'), '…reachable from the one layout control, which cycles through it') ok(/resolveSrc\(coverSrc\(r\.page\), doc\)/.test(render), '…and a card asks coverSrc for the picture, so a remote cover is refused there too') @@ -3594,5 +3604,277 @@ function fsTable(f: string): string { } +// ---- the CALENDAR layout --------------------------------------------------- +// WHAT THIS PROVES, and every check below is BEHAVIOURAL — the functions are +// imported and run. A source grep over render.ts would have passed while the +// grid was empty, which is the class of failure this zone has measured twice. +// +// 1. The grid has the right NUMBER OF CELLS. Four, five and six week months +// all exist, and the reader's first day of the week moves the boundary — +// February 2026 is exactly four weeks starting Sunday and five starting +// Monday. A hard-coded 35 loses the last days of a six-week month with no +// symptom but a missing entry. +// 2. Every date is built from COMPONENTS, so the answer is the same at UTC+14 +// and UTC-11. This file is run under both. +// 3. Nothing is DROPPED. A page the rule finds no date for is in `undated`, +// never gone. +// 4. Untrusted input cannot reach `Object.prototype` through any of it. +{ + const calDoc = (fields: unknown[] = DEFAULT_FIELDS as unknown[]): SpacesDoc => + ({ fields, pages: [] } as unknown as SpacesDoc) + const crow = (id: string, journal?: string, values: Record = {}): IssueRow => + ({ + page: { id, title: id, blocks: [], ...(journal ? { journal } : {}) } as unknown as Page, + values: new Map(Object.entries(values)), + }) + + // --- the shape cycle, on the layout cycle's own discipline --------------- + ok((VIEW_LAYOUTS as readonly string[]).includes('calendar'), 'a view can be a calendar') + ok(layoutOf('calendar') === 'calendar' && nextLayout('gallery') === 'calendar', + '…reachable from the ONE layout control, which cycles through it') + ok(CAL_SPANS[0] === 'month' && spanOf(undefined) === 'month', + 'month is the ABSENT key, so a view nobody toggled carries no span at all') + ok(nextSpan('month') === 'timeline' && nextSpan('timeline') === 'month', + 'the two shapes toggle, and the toggle closes') + for (const evil of ['toString', 'constructor', 'valueOf', 'hasOwnProperty', '__proto__']) { + ok((CAL_SPANS as readonly string[]).includes(spanOf(evil)), + `span:${JSON.stringify(evil)} resolves to a real shape (${spanOf(evil)}), never a native function`) + ok((CAL_SPANS as readonly string[]).includes(nextSpan(evil)), + '…and what follows it is a shape too, so data-next is never a function body') + } + ok(spanOf('quarter') === 'month', 'a span from a NEWER build falls back to the month grid') + + // --- WHICH DATE ---------------------------------------------------------- + const cdoc = calDoc() + ok(dateOf(cdoc, crow('a', '2026-08-06')) === '2026-08-06', + 'a journal entry is dated by its journal date') + ok(dateOf(cdoc, crow('b', undefined, { due: '2026-08-09' })) === '2026-08-09', + '…a page without one falls to the first date FIELD it carries a value for') + ok(dateOf(cdoc, crow('c', '2026-08-06', { due: '2027-01-01' })) === '2026-08-06', + '…and the journal date wins when a page has both') + ok(dateOf(cdoc, crow('e')) === '', 'a page with neither has NO date rather than a guessed one') + // 2026-13-99 is digit-shaped and is not a day; every Date-based formatter + // rolls it into some OTHER real date, which is the confident wrong answer + ok(dateOf(cdoc, crow('f', undefined, { due: '2026-13-99' })) === '', + 'a digit-shaped non-date is undated, never rolled over into a day it is not') + ok(dateOf(cdoc, crow('g', undefined, { due: 20260809 })) === '', + '…and a number in a date field is undated too, rather than stringified into one') + { + // the rule is "the first date field WITH A VALUE", not "the first date + // field": a page with an empty Due and a filled Published is dated by + // Published, or the rule would drop it for carrying the wrong empty box + const two = calDoc([ + { key: 'due', label: 'Due', vt: 'date' }, + { key: 'pub', label: 'Published', vt: 'date' }, + ]) + ok(dateOf(two, crow('h', undefined, { due: '', pub: '2026-03-04' })) === '2026-03-04', + 'an EMPTY date field is skipped for the next one, not treated as the answer') + ok(dateFieldsOf(two).length === 2, 'the hint names every date field the schema declares') + } + + // --- NOTHING IS DROPPED -------------------------------------------------- + { + const rows = [crow('a', '2026-08-06'), crow('b', '2026-08-06'), + crow('c', undefined, { due: '2026-08-09' }), crow('d'), crow('e')] + const split = splitByDate(cdoc, rows) + ok(split.days.get('2026-08-06')?.length === 2, 'two pages on one day share a cell') + ok(split.undated.length === 2, 'the undated pages are KEPT, in their own bucket') + const seen = [...split.days.values()].reduce((n, v) => n + v.length, 0) + split.undated.length + ok(seen === rows.length, + `every row the view holds is somewhere in the calendar (${seen}/${rows.length})`) + } + { + // the day keys come out of a document someone sent you. A plain object + // would read `days['__proto__']` back as the prototype and `days['toString']` + // as a native function; a Map has no prototype keys to collide with. + const evil = splitByDate(calDoc([{ key: 'due', label: 'Due', vt: 'date' }]), + [crow('x', '2026-08-06'), crow('y')]) + ok(evil.days.get('__proto__') === undefined && evil.days.get('toString') === undefined, + 'the day index reaches Object.prototype for nothing') + ok(evil.days.size === 1, '…and holds exactly the days it was given') + } + + // --- THE GRID, counted --------------------------------------------------- + // Sunday-first and Monday-first are different grids for the same month, so + // the count is asked of both explicitly rather than of whatever this machine + // happens to be set to. + for (const [ym, loc, want, why] of [ + // Feb 2026 has 28 days and 1 Feb 2026 is a SUNDAY: exactly four weeks in a + // Sunday-first locale, and five in a Monday-first one. The one month where + // a grid can be 28 cells at all. + ['2026-02', 'en-US', 28, 'a 28-day month starting on the first weekday is FOUR weeks'], + ['2026-02', 'en-GB', 35, '…and FIVE weeks when the same month starts on the last one'], + // 1 Aug 2026 is a Saturday: six weeks whichever end the week starts. + ['2026-08', 'en-GB', 42, 'a month that spills past five weeks gets SIX, never a clipped five'], + ['2026-08', 'en-US', 42, '…in a Sunday-first locale too'], + // 1 Sep 2026 is a Tuesday: the ordinary five. + ['2026-09', 'en-GB', 35, 'and the ordinary month is five'], + ] as const) { + const cells = monthGrid(ym, loc) + ok(cells.length === want, `${why} — ${ym}/${loc} is ${cells.length} cells`) + ok(cells.length % 7 === 0, `…and ${ym}/${loc} is whole weeks`) + } + { + // EVERY DAY OF THE MONTH IS PRESENT EXACTLY ONCE. The cell count being + // right is necessary and not sufficient — an off-by-one in the lead would + // give 35 correct-looking cells with the 31st missing and the 30th twice. + for (const ym of ['2026-01', '2026-02', '2024-02', '2026-08', '2026-09', '2026-12']) { + const inMonth = monthGrid(ym, 'en-GB').filter((c) => c.inMonth).map((c) => c.iso) + const days = new Date(Number(ym.slice(0, 4)), Number(ym.slice(5)), 0).getDate() + const want = Array.from({ length: days }, (_, i) => `${ym}-${String(i + 1).padStart(2, '0')}`) + ok(inMonth.join(',') === want.join(','), + `${ym}: all ${days} days present, once each, in order`) + } + ok(monthGrid('2024-02', 'en-GB').filter((c) => c.inMonth).length === 29, + 'a leap February has 29 days, from the calendar rather than from a table') + ok(monthGrid('2026-02', 'en-GB').filter((c) => c.inMonth).length === 28, + '…and a non-leap one has 28') + } + { + // the grid is CONTIGUOUS: every cell is the day after the one before it, + // across the month boundaries at both ends + const cells = monthGrid('2026-08', 'en-GB') + let contiguous = true + for (let i = 1; i < cells.length; i++) { + if (daysApart(cells[i - 1].iso, cells[i].iso) !== 1) contiguous = false + } + ok(contiguous, 'the grid is one unbroken run of days, leading and trailing weeks included') + ok(cells[0].inMonth === false && cells[cells.length - 1].inMonth === false, + '…and the days outside the month are marked as such rather than blanked out') + } + { + // EVERY GRID STARTS ON THE READER'S FIRST WEEKDAY, and ends the day before + // it. Added after a sabotage run: replacing the component-built date with + // `new Date(`${ym}-01T00:00:00Z`)` — the exact UTC-parse bug journal.ts + // exists to warn about — slid the whole grid one day west of Greenwich, and + // the day-coverage checks above ALL PASSED, because a uniform shift still + // contains every day of the month exactly once. It just puts them in the + // wrong columns. The weekday of the first cell is the invariant a shifted + // grid cannot satisfy; the day list is not. + const dow = (iso: string): number => { + const [y, m, d] = iso.split('-').map(Number) + return new Date(y, m - 1, d).getDay() + } + for (const [ym, loc] of [ + ['2026-02', 'en-US'], ['2026-02', 'en-GB'], ['2026-08', 'en-US'], + ['2026-08', 'en-GB'], ['2026-09', 'ja'], ['2026-12', 'de'], ['2024-02', 'pt'], + ] as const) { + const cells = monthGrid(ym, loc) + ok(dow(cells[0].iso) === firstWeekday(loc), + `${ym}/${loc}: the grid begins on the reader’s own first weekday`) + ok(dow(cells[cells.length - 1].iso) === (firstWeekday(loc) + 6) % 7, + `…and ends on the day before it, so no week is half-drawn`) + const days = new Date(Number(ym.slice(0, 4)), Number(ym.slice(5)), 0).getDate() + ok(cells.some((c) => c.iso === `${ym}-01`) + && cells.some((c) => c.iso === `${ym}-${String(days).padStart(2, '0')}`), + `…and holds both ends of ${ym} rather than clipping one`) + } + } + ok(monthGrid('2026-13', 'en-GB').length === 0 && monthGrid('nonsense', 'en-GB').length === 0, + 'a month that is not a month draws no grid rather than a garbage one') + + // --- FIRST DAY OF THE WEEK, and the column headings ---------------------- + ok(firstWeekday('en-US') === 0, 'the week starts on Sunday in en-US') + ok(firstWeekday('en-GB') === 1, '…and on Monday in en-GB') + for (const loc of ['en-US', 'en-GB', 'ja', 'de', 'pt', 'zh-Hans']) { + const names = weekdayNames(loc) + ok(names.length === 7 && new Set(names).size === 7, + `${loc}: seven distinct weekday names, from Intl rather than a hand-written map`) + } + // FROM INTL, not from a table of English words. Sabotaging the formatter into + // a hardcoded ['Sun','Mon',…] passed every check above — seven distinct names + // is true of an English array too. Comparing across scripts is what makes the + // difference visible, and it is the same failure the extractor sweep + // punishes: a hand-written weekday map reaches no catalog and ships English + // to all eight locales while the packer reports 100%. + // AS SETS, not as ordered lists. The first draft compared `join(',')` and the + // `ja` half of it passed under the sabotage FOR THE WRONG REASON: ja starts + // its week on Sunday and en-GB on Monday, so two identical English arrays + // come back rotated and compare unequal. The set has no rotation to hide in. + const sameNames = (a: string, b: string): boolean => + [...new Set(weekdayNames(a))].sort().join(',') === [...new Set(weekdayNames(b))].sort().join(',') + ok(!sameNames('ja', 'en-GB'), + 'the weekday names are the READER’S, not English translated by nobody') + ok(!sameNames('de', 'en-GB'), + '…in every script, not only the ones that do not use the Latin alphabet') + { + // the headings are ROTATED with the week, not merely translated: getting + // this wrong labels the columns correctly and puts every entry one column + // out, which looks right until you check a date + const us = weekdayNames('en-US'), gb = weekdayNames('en-GB') + ok(us[0] === gb[6] && us[1] === gb[0], + 'a Sunday-first locale gets the same seven names ROTATED, not relabelled') + const first = monthGrid('2026-02', 'en-US')[0] + ok(first.iso === '2026-02-01' && new Date(2026, 1, 1).getDay() === 0, + '…and the grid starts on the reader’s own first weekday') + } + ok(/2026/.test(monthLabel('2026-08', 'en-GB')) && monthLabel('2026-08', 'en-GB') !== '2026-08', + 'the month is named through Intl, so it is the reader’s own word and never stored') + ok(monthLabel('2026-08', 'ja') !== monthLabel('2026-08', 'de'), + '…and two readers of one file see two different words for one stored date') + + // --- MONTH ARITHMETIC ---------------------------------------------------- + ok(stepMonth('2026-12', 1) === '2027-01' && stepMonth('2026-01', -1) === '2025-12', + 'stepping past December carries the year') + ok(stepMonth('2026-08', 6) === '2027-02' && stepMonth('2026-08', -8) === '2025-12', + '…in both directions, by any number of months') + ok(monthOf('2026-08-06') === '2026-08' && monthOf('2026-13-99') === '', + 'a non-date belongs to no month') + + // --- DAYS APART, the one place UTC is right ------------------------------ + ok(daysApart('2026-08-06', '2026-08-07') === 1, 'one day apart is one') + ok(daysApart('2026-08-07', '2026-08-06') === -1, '…and signed') + ok(daysApart('2026-02-28', '2026-03-01') === 1, 'February rolls into March') + ok(daysApart('2024-02-28', '2024-03-01') === 2, '…with the leap day in between when there is one') + ok(daysApart('2025-12-31', '2026-01-01') === 1, 'and the year boundary is one day, not 365') + // THE DST TRAP, stated as an assertion rather than as a comment. In + // Europe/Berlin 29 March 2026 is 23 hours long and 25 October is 25; in + // America/Los_Angeles it is 8 March and 1 November. Local-midnight + // subtraction gives 0.958 and 1.042 days there and rounds to the wrong + // answer. Both pairs are checked in every timezone this rig runs under. + for (const [a, b] of [['2026-03-28', '2026-03-30'], ['2026-10-24', '2026-10-26'], + ['2026-03-07', '2026-03-09'], ['2026-10-31', '2026-11-02']] as const) { + ok(daysApart(a, b) === 2, + `${a} → ${b} is exactly 2 days across a DST boundary (TZ=${process.env.TZ ?? 'system'})`) + } + + // --- WHICH MONTH IT OPENS ON --------------------------------------------- + ok(defaultMonth(['2026-08-06', '2026-09-02'], '2026-08-20') === '2026-08', + 'the grid opens on TODAY’S month when anything falls in it') + ok(defaultMonth(['2019-04-02', '2019-04-30'], '2026-08-20') === '2019-04', + '…and on the data’s own month when nothing does, rather than an empty grid') + ok(defaultMonth([], '2026-08-20') === '2026-08', 'an empty view opens on this month') + // 20 August → 1 June is 80 days back and → 1 November is 73 days on, so the + // FUTURE one is nearer. Written with the numbers checked rather than assumed: + // the first draft of this line asserted June because June "looks" closer on + // the page, and the rig caught it. + ok(defaultMonth(['2026-06-01', '2026-11-01'], '2026-08-20') === '2026-11', + 'the NEAREST dated row decides, and it can be the one in the future') + ok(defaultMonth(['2026-06-01', '2026-12-25'], '2026-08-20') === '2026-06', + '…or the one in the past, when that is the nearer') + ok(defaultMonth(['2026-08-10', '2026-08-30'], '2026-08-20') === '2026-08', + '…and a tie goes to the later of the two') + ok(defaultMonth(['nonsense', '2019-04-02'], '2026-08-20') === '2019-04', + 'a junk value in the list is ignored rather than deciding the month') + + // --- THE TIMELINE -------------------------------------------------------- + { + const rows = [crow('a', '2026-01-02'), crow('b', '2026-08-06'), crow('c', '2026-03-04')] + const days = timelineDays(splitByDate(cdoc, rows).days) + ok(days.join(',') === '2026-08-06,2026-03-04,2026-01-02', + 'the timeline reads NEWEST FIRST, whatever order the pages are in') + } + + // --- THE HINT ------------------------------------------------------------ + // It has to SAY the rule, because the rule is fixed rather than chosen. And + // it goes through t() as a literal with an interpolated list — a sentence + // assembled from fragments does not survive the eight catalogs. + ok(/Due/.test(dateHint(calDoc())), 'the view says out loud which date it used') + ok(dateHint(calDoc([{ key: 'x', label: 'X', vt: 'text' }])) === 'Dated by the journal date.', + '…and says so differently when the schema declares no date field at all') + ok(dateHint(calDoc([{ key: 'due', vt: 'date' }])).includes('due'), + 'a schema entry with no label falls back to its key rather than printing undefined') +} + console.log(`\n${checks - failures}/${checks} checks passed`) if (failures) process.exit(1) diff --git a/scripts/test-spaces.mjs b/scripts/test-spaces.mjs index ae197b89..3a433e23 100755 --- a/scripts/test-spaces.mjs +++ b/scripts/test-spaces.mjs @@ -39,11 +39,21 @@ const esbuild = join(root, 'slides/node_modules/.bin/esbuild') // The timezone lists are not decoration. A date test that only runs in one // timezone has not been run: Kiritimati is UTC+14 and Lord Howe is a half-hour // DST offset, which is where "add 86,400,000 ms" stops being "add a day". -const TZS_JOURNAL = ['UTC', 'Europe/Berlin', 'America/Los_Angeles', 'Pacific/Kiritimati', 'Australia/Lord_Howe'] +// Niue is UTC-11, the far western end, and it is the mirror of Kiritimati: a +// date built by parsing an ISO string (UTC midnight) is the PREVIOUS day there +// and the SAME day at +14, so one timezone alone cannot tell a correct +// implementation from a broken one. +const TZS_JOURNAL = ['UTC', 'Europe/Berlin', 'America/Los_Angeles', 'Pacific/Kiritimati', 'Pacific/Niue', 'Australia/Lord_Howe'] const TZS_CALC = ['UTC', 'Europe/Berlin', 'America/Los_Angeles', 'Pacific/Kiritimati'] const RIGS = [ - { name: 'model', file: 'scripts/test-spaces-model.ts' }, + // The model rig carries the CALENDAR layout's arithmetic now — month grids, + // day counts, "which month does this open on" — which is date code and + // therefore timezone code. CI still runs this rig once, in one timezone; the + // one-line change to that step is queued on the board rather than made here, + // because ci.yml is the standing conflict magnet and five sibling branches + // are in flight. Until it lands, THIS is where the matrix lives. + { name: 'model', file: 'scripts/test-spaces-model.ts', tzs: TZS_JOURNAL }, { name: 'agent', file: 'scripts/test-spaces-agent.ts' }, { name: 'journal', file: 'scripts/test-spaces-journal.ts', tzs: TZS_JOURNAL }, { name: 'calc', file: 'scripts/test-spaces-calc.ts', tzs: TZS_CALC }, diff --git a/spaces/CHANGELOG.md b/spaces/CHANGELOG.md index 820b8829..a09d4b9f 100644 --- a/spaces/CHANGELOG.md +++ b/spaces/CHANGELOG.md @@ -477,6 +477,36 @@ Versions follow `0.MINOR.PATCH` while pre-1.0. 2026, from the same file. `bento.journal()` opens today's for an agent, and `bento.journal('2026-08-06')` any day's. +- **A view can be a CALENDAR.** The fifth shape on the one layout button — + board, list, table, gallery, calendar — and the one that answers *when*. It + has two forms behind a second button: a month grid, and a timeline that reads + newest first. Two forms rather than two entries in the cycle, because they are + one question at two densities (a month grid is useless on dates spread over + years, and a timeline cannot show you the shape of a week), and because the + layout control is a cycle whose cost is one click for everybody every time + they pass a shape they did not want. + + **Which date a page sits on is a rule, not a setting, and the view says the + rule out loud**: its journal date if it is a journal entry, otherwise the + first date field in the schema it carries a real date for. A page with neither + is listed under "No date" — visible, because a calendar quietly holding fewer + pages than the count beside its own title is a view lying about what it + contains, and the pages it would drop are exactly the ones somebody forgot to + date. A value that is digit-shaped but not a day (`2026-13-99`) is no date + rather than a confident wrong one. + + Month names, weekday names and **which day the week starts on** all come from + the reader's own locale, so the same file is a Sunday-first 2026年9月 in Tokyo + and a Monday-first September 2026 in London. Nothing formatted is ever stored. + Measured in a built shell: February 2026 draws 28 cells in four rows, August + 2026 draws 42 in six, September 35 in five — the count is derived from the + month and the reader, never assumed. + + `layout: "calendar"` and `span: "timeline"` are additive: verified against a + build that has never heard of either, which renders the board and round-trips + both keys untouched. And `board`/`month` stay the ABSENT keys — a view cycled + all the way round, span and all, is byte-identical to one nobody touched. + ## [0.1.0] — 2026-08-03 First release. diff --git a/spaces/src/calendar.ts b/spaces/src/calendar.ts new file mode 100644 index 00000000..4a38ac9e --- /dev/null +++ b/spaces/src/calendar.ts @@ -0,0 +1,521 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 The Bento authors +// The CALENDAR layout: a view laid out by date. +// +// ONE ENTRY IN THE CYCLE, TWO SHAPES BEHIND IT. A month grid and a timeline are +// genuinely different pictures — a grid answers "what is this week like" and +// fails completely on data spread over years (thirty-six mostly-empty months to +// page through), while a timeline answers "what happened, in order" and cannot +// show you a shape of a month at all. Both are needed, because this app holds +// both kinds of dated page: journals are dense and daily, a reading list's +// dates are sparse and span years. +// +// They are still not PEERS of board/list/table/gallery. Those four answer four +// different questions; these two answer ONE question ("when?") at two +// densities. And the layout control is a CYCLE, whose cost is linear: a sixth +// shape makes getting back to the board from the middle worse than the shape is +// worth. So the cycle gains one entry, `calendar`, and the month/timeline +// choice is a second control that appears only while you are in it — exactly +// how `groupBy` already works, a board-only parameter with its own button that +// the renderer hides for every other shape. +// +// WHICH DATE A PAGE SORTS BY, and it is a fixed rule rather than a setting: +// +// 1. `page.journal`, when it is a real ISO date. The page IS a day; nothing +// about that is ambiguous. +// 2. otherwise the first `date`-typed field IN SCHEMA ORDER that this page +// carries a real ISO value for. Not "the first date field" — a page with +// an empty Due and a filled Published is dated by Published, or the rule +// would drop it for carrying the wrong empty box. +// 3. otherwise it has NO DATE, and it is shown saying so. Never dropped: a +// view that silently holds fewer pages than its own count is the failure +// this whole file exists to avoid. +// +// The rule is not configurable and the UI says what it is, in words, above the +// grid. A `dateBy` key would be a permanent format field bought to express a +// preference nobody has yet asked for. +// +// TIMEZONES. Every date here is built from COMPONENTS in the reader's own +// timezone — `new Date(y, m - 1, d)` — and never parsed from an ISO string. +// `new Date('2026-01-01')` is UTC midnight by spec, which is 31 December for +// every reader west of Greenwich, so it would put a journal entry in the wrong +// month for a third of the world. journal.ts carries the long version of this +// argument; the rigs run under Kiritimati (+14) and Niue (-11) because that is +// where the bug shows. +// +// The ONE place UTC is correct is `daysApart`, which subtracts two calendar +// dates: built with `Date.UTC` precisely so that no daylight-saving boundary +// sits between them and a difference in milliseconds is an exact whole number +// of days. Local `Date`s would be 23 or 25 hours apart twice a year. +// +// LOCALE. Month names, weekday names and the reader's first day of the week all +// come from `Intl`, never from a table in this file. Two reasons and both are +// load-bearing: a hand-written month map is untranslatable (the extractor +// sweeps `t()` LITERALS, so `t(MONTHS[m])` reaches no catalog while the packer +// reports 100%), and the week does not start on the same day everywhere — the +// grid is shifted, not just relabelled. + +import type { SpacesDoc } from './model.ts' +import { fieldsOf, type FieldSpec, type IssueRow } from './fields.ts' +import { isISO, journalLabel, todayISO } from './journal.ts' +import { t } from './i18n.ts' + +// ---- the two shapes -------------------------------------------------------- + +/** + * The shapes the calendar layout can take, and the order its button cycles. + * + * `month` is the ABSENT key, exactly as `board` is for the layout itself: a + * view toggled to the timeline and back is byte-identical to one nobody + * touched. A STRING rather than a boolean because `week` and `year` are the + * obvious next two, and widening a boolean afterwards cannot be done at all. + */ +export const CAL_SPANS = ['month', 'timeline'] as const +export type CalSpan = (typeof CAL_SPANS)[number] + +/** + * The shape a calendar is ACTUALLY in, whatever its block claims. + * + * The same discipline `layoutOf` is written in, for the same reason: a view + * block is plain JSON in a file someone sent you, and `SPAN['toString']` on an + * object literal is a truthy native function whose string form is + * `function toString() { [native code] }` — which is what the layout button + * once rendered as its own label. A membership test on the tuple can never + * reach `Object.prototype`; a lookup can. + */ +export function spanOf(raw: unknown): CalSpan { + const s = String(raw ?? 'month') + return (CAL_SPANS as readonly string[]).includes(s) ? (s as CalSpan) : 'month' +} + +/** The next shape: month → timeline → month. */ +export function nextSpan(raw: unknown): CalSpan { + const here = spanOf(raw) + return CAL_SPANS[(CAL_SPANS.indexOf(here) + 1) % CAL_SPANS.length] +} + +// ---- which date ------------------------------------------------------------ + +/** Every `date`-typed field the schema declares, in its declared order. */ +export const dateFieldsOf = (doc: SpacesDoc): FieldSpec[] => + fieldsOf(doc).filter((f) => f.vt === 'date') + +/** + * The date this row sits on — `''` when it has none. + * + * See the header for the rule. A value that is not a real ISO date is UNDATED + * rather than guessed at: `2026-13-99` is digit-shaped and is not a day, and + * every Date-based formatter would roll it confidently into some other real + * date. Showing it in the "No date" bucket is visible and true; showing it on + * 9 January 2027 is neither. + */ +export function dateOf(doc: SpacesDoc, row: IssueRow): string { + const j = row.page.journal + if (typeof j === 'string' && isISO(j)) return j + for (const f of dateFieldsOf(doc)) { + const v = row.values.get(f.key) + if (typeof v === 'string' && isISO(v)) return v + } + return '' +} + +/** The rows this view holds, split into days and the ones with no date. */ +export interface DateSplit { + /** ISO day → the rows on it, in the order they arrived */ + days: Map + /** rows the rule found no date for. Shown, never dropped. */ + undated: IssueRow[] +} + +export function splitByDate(doc: SpacesDoc, rows: readonly IssueRow[]): DateSplit { + // A Map, not an object: the keys come from a document someone sent you, and + // `days['__proto__'] = []` on an object literal writes nowhere and reads back + // as the prototype. A Map has no prototype keys to collide with. + const days = new Map() + const undated: IssueRow[] = [] + for (const r of rows) { + const iso = dateOf(doc, r) + if (!iso) { undated.push(r); continue } + const at = days.get(iso) + if (at) at.push(r) + else days.set(iso, [r]) + } + return { days, undated } +} + +// ---- calendar arithmetic --------------------------------------------------- + +const pad = (n: number) => String(n).padStart(2, '0') +const isoOf = (at: Date) => `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}` + +/** The month an ISO day belongs to, as `YYYY-MM`. `''` for a non-date. */ +export const monthOf = (iso: string): string => (isISO(iso) ? iso.slice(0, 7) : '') + +const MONTH_SHAPE = /^\d{4}-\d{2}$/ + +/** Is this a `YYYY-MM` naming a real month? */ +export const isMonth = (v: unknown): boolean => + typeof v === 'string' && MONTH_SHAPE.test(v) && Number(v.slice(5)) >= 1 && Number(v.slice(5)) <= 12 + +/** + * The month `n` months from `ym`, on the CALENDAR. + * + * Through the Date constructor so December + 1 is next January and the year + * carries, rather than `12 + 1 = 13`. Day 1 is safe here in a way day 31 would + * not be: `new Date(2026, 0, 31 + 1 month)` is 3 March. + */ +export function stepMonth(ym: string, n: number): string { + if (!isMonth(ym)) return ym + const y = Number(ym.slice(0, 4)), m = Number(ym.slice(5)) + const at = new Date(y, m - 1 + n, 1) + return `${at.getFullYear()}-${pad(at.getMonth() + 1)}` +} + +/** + * Whole days from `a` to `b`. + * + * `Date.UTC`, deliberately, and it is the only UTC in this file. Two local + * midnights either side of a daylight-saving change are 23 or 25 hours apart, + * so dividing their difference by 86,400,000 gives 0.958 or 1.042 and rounds + * wrong. Two UTC midnights are always an exact multiple of a day, and since + * both inputs are calendar dates rather than instants, no timezone applies to + * either. + */ +export function daysApart(a: string, b: string): number { + const [ay, am, ad] = a.split('-').map(Number) + const [by, bm, bd] = b.split('-').map(Number) + return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86400000) +} + +/** + * WHICH MONTH THE GRID OPENS ON. + * + * Today's month when anything at all falls in it — that is the month a reader + * of a journal wants and the one they would otherwise have to navigate to every + * single time. Otherwise the month of the dated row NEAREST today, so a space + * whose entries are all in 2019 opens on 2019 instead of on an empty grid with + * no clue that the data is elsewhere. Ties go to the later of the two, because + * "the next thing" beats "the last thing" when both are equally far off. + */ +export function defaultMonth(isos: readonly string[], today: string): string { + const here = monthOf(today) || monthOf(todayISO()) + const dated = isos.filter((d) => isISO(d)) + if (!dated.length) return here + if (dated.some((d) => monthOf(d) === here)) return here + let best = dated[0] + let bestGap = Math.abs(daysApart(best, today)) + for (const d of dated) { + const gap = Math.abs(daysApart(d, today)) + if (gap < bestGap || (gap === bestGap && d > best)) { best = d; bestGap = gap } + } + return monthOf(best) +} + +/** + * The reader's first day of the week, `0` = Sunday … `6` = Saturday. + * + * The week does not start on Monday everywhere — Sunday in the US, Japan and + * Brazil, Saturday across much of the Middle East — and this SHIFTS THE GRID + * rather than relabelling it, so getting it wrong puts every entry in the wrong + * column. `Intl.Locale`'s week info is the only place a browser knows this; + * it arrived as a method (`getWeekInfo()`) and shipped in some engines as a + * property (`weekInfo`), and older ones have neither, so all three cases are + * handled and the floor is Monday (ISO 8601). + */ +export function firstWeekday(loc?: string): number { + try { + const L = new Intl.Locale(loc || 'en') as unknown as { + getWeekInfo?: () => { firstDay?: number } + weekInfo?: { firstDay?: number } + } + const fd = (typeof L.getWeekInfo === 'function' ? L.getWeekInfo()?.firstDay : undefined) + ?? L.weekInfo?.firstDay + // 1 = Monday … 7 = Sunday in the spec; 7 % 7 === 0 puts Sunday first. + if (typeof fd === 'number' && fd >= 1 && fd <= 7) return fd % 7 + } catch { + // an engine with no Intl.Locale at all, or a tag it will not parse + } + return 1 +} + +/** + * The seven column headings, in the reader's own order and language. + * + * From Intl and a KNOWN WEEK, never a table of English words: 1 January 2024 + * was a Monday, so 7 January 2024 was a Sunday and `+ weekday` walks the week + * from there. Built local-midnight, so no timezone can shift a name by a day. + */ +export function weekdayNames(loc?: string): string[] { + const start = firstWeekday(loc) + const out: string[] = [] + for (let i = 0; i < 7; i++) { + const at = new Date(2024, 0, 7 + ((start + i) % 7)) + try { + out.push(new Intl.DateTimeFormat(loc, { weekday: 'short' }).format(at)) + } catch { + out.push(['S', 'M', 'T', 'W', 'T', 'F', 'S'][(start + i) % 7]) + } + } + return out +} + +/** "August 2026", in the reader's language. Falls back to the raw `YYYY-MM`. */ +export function monthLabel(ym: string, loc?: string): string { + if (!isMonth(ym)) return ym + const y = Number(ym.slice(0, 4)), m = Number(ym.slice(5)) + try { + return new Intl.DateTimeFormat(loc, { month: 'long', year: 'numeric' }).format(new Date(y, m - 1, 1)) + } catch { + return ym + } +} + +export interface CalCell { + iso: string + /** the day number as it is printed */ + day: number + /** false for the days of the neighbouring months that fill out the weeks */ + inMonth: boolean +} + +/** + * The cells of one month's grid, in reading order, WHOLE WEEKS. + * + * The classic failure is a fixed cell count. A month grid is 28, 35 or 42 cells + * depending on the month and on where the reader's week starts — February 2026 + * is exactly four weeks if your week starts on Sunday and five if it starts on + * Monday, and August 2026 is six either way. Hard-coding 42 leaves a trailing + * empty week most months; hard-coding 35 silently LOSES the last days of a + * six-week month. So the count is derived, and the rig counts cells. + * + * The neighbouring months' days are real cells rather than blanks, and entries + * on them are drawn: a grid whose corners are holes reads as broken, and an + * entry on the 1st of next month is exactly what somebody looking at the last + * week of this one wants to see. + */ +export function monthGrid(ym: string, loc?: string): CalCell[] { + if (!isMonth(ym)) return [] + const y = Number(ym.slice(0, 4)), m = Number(ym.slice(5)) + const lead = (new Date(y, m - 1, 1).getDay() - firstWeekday(loc) + 7) % 7 + // day 0 of the NEXT month is the last day of this one — the standard way to + // ask a Date how long a month is, leap years included + const days = new Date(y, m, 0).getDate() + const total = Math.ceil((lead + days) / 7) * 7 + const cells: CalCell[] = [] + for (let i = 0; i < total; i++) { + // the constructor rolls a negative or over-long day into the neighbouring + // month for us, which is the whole reason the loop can be this short + const at = new Date(y, m - 1, 1 - lead + i) + cells.push({ + iso: isoOf(at), + day: at.getDate(), + inMonth: at.getMonth() === m - 1 && at.getFullYear() === y, + }) + } + return cells +} + +/** + * The days a timeline lists, NEWEST FIRST. + * + * One direction, chosen rather than configured. Newest-first is the order this + * app already reads dated pages in (`journalsOf`), the dominant dated content + * in a space is journal entries, and the end you want without scrolling is the + * recent one — a five-year journal opened oldest-first is five years of + * scrolling to reach today. + */ +export const timelineDays = (days: Map): string[] => + [...days.keys()].sort((a, b) => b.localeCompare(a)) + +// ---- what the header says -------------------------------------------------- + +/** + * The sentence above the grid saying WHICH DATE it used. + * + * Said out loud because the rule is fixed: a reader looking at a page in the + * wrong week needs to know the view is reading `Due` and not the thing they + * were thinking of. Whole sentences with one interpolated list, not a sentence + * assembled from fragments — the fragments do not agree in gender or order + * across the eight catalogs. + */ +export function dateHint(doc: SpacesDoc): string { + const fields = dateFieldsOf(doc) + if (!fields.length) return t('Dated by the journal date.') + return t('Dated by the journal date, then {fields}.', { + fields: fields.map((f) => (typeof f.label === 'string' && f.label ? f.label : f.key)).join(', '), + }) +} + +// ---- the DOM --------------------------------------------------------------- + +/** + * WHICH MONTH EACH VIEW IS SHOWING — viewer state, never the document. + * + * The same call the reduce-motion preference and the locale make: where you + * have scrolled to is not a property of the document, and writing it there + * would make paging a month a document edit — an undo entry, a dirty flag, a + * collaboration op, and a file that differs from the one you were sent because + * you looked at March. + * + * Keyed by block id and held for the session, so the editor's full repaint + * (every view control commits and calls `paintPage`) does not throw the month + * away underneath you. + */ +const SHOWN = new Map() + +/** Test seam: forget every remembered month. */ +export const forgetMonths = (): void => { SHOWN.clear() } + +export interface CalendarCtx { + doc: SpacesDoc + rows: readonly IssueRow[] + blockId: string + span: CalSpan + locale?: string + /** false in reading view, on paper, and in a locked space */ + editable?: boolean + /** the view's own card builder, so a timeline entry is the same card a list draws */ + card: (row: IssueRow) => HTMLElement + /** for the rigs — what "today" is, so a fixture is not a different test tomorrow */ + today?: string +} + +/** A link to a page, or a plain label when nothing here is clickable. */ +function pageLink(row: IssueRow, editable: boolean | undefined, cls: string): HTMLElement { + const el = document.createElement(editable === false ? 'span' : 'a') + el.className = cls + if (el instanceof HTMLAnchorElement) el.href = `#p/${row.page.id}` + el.dataset.page = row.page.id + el.textContent = row.page.title || t('Untitled') + return el +} + +/** + * The whole layout, drawn into `host`. + * + * `host` is emptied and rebuilt by the month arrows IN PLACE, which is why the + * grid's own entries are plain links and carry no controls: the editor wires + * `[data-set-field]` buttons once per repaint (`wireBoard`), so a control this + * function re-created between repaints would be dead. A cell is too small for + * a status picker anyway. The TIMELINE has room and no in-place re-render, so + * it uses the view's real card. + */ +export function renderCalendar(host: HTMLElement, ctx: CalendarCtx): void { + const today = ctx.today ?? todayISO() + const split = splitByDate(ctx.doc, ctx.rows) + + const hint = document.createElement('p') + hint.className = 'sp-cal-hint' + hint.textContent = dateHint(ctx.doc) + host.appendChild(hint) + + const body = document.createElement('div') + body.className = 'sp-cal' + host.appendChild(body) + + const draw = (): void => { + body.replaceChildren() + if (ctx.span === 'timeline') drawTimeline(body, ctx, split) + else drawMonth(body, ctx, split, today, draw) + drawUndated(body, ctx, split) + } + draw() +} + +function drawMonth( + body: HTMLElement, ctx: CalendarCtx, split: DateSplit, today: string, redraw: () => void, +): void { + const shown = SHOWN.get(ctx.blockId) + ?? defaultMonth([...split.days.keys()], today) + SHOWN.set(ctx.blockId, shown) + + const bar = document.createElement('div') + bar.className = 'sp-cal-bar' + const step = (n: number, label: string, glyph: string): HTMLButtonElement => { + const b = document.createElement('button') + b.type = 'button' + b.className = 'sp-btn sp-cal-step' + b.textContent = glyph + b.title = label + b.setAttribute('aria-label', label) + b.addEventListener('click', () => { SHOWN.set(ctx.blockId, stepMonth(shown, n)); redraw() }) + return b + } + const title = document.createElement('span') + title.className = 'sp-cal-month' + title.textContent = monthLabel(shown, ctx.locale) + const now = document.createElement('button') + now.type = 'button' + now.className = 'sp-btn sp-cal-step' + now.textContent = t('Today') + now.addEventListener('click', () => { SHOWN.set(ctx.blockId, monthOf(today)); redraw() }) + bar.append(step(-1, t('Previous month'), '‹'), title, step(1, t('Next month'), '›'), now) + body.appendChild(bar) + + const grid = document.createElement('div') + grid.className = 'sp-cal-grid' + for (const name of weekdayNames(ctx.locale)) { + const h = document.createElement('div') + h.className = 'sp-cal-wd' + h.textContent = name + grid.appendChild(h) + } + + for (const cell of monthGrid(shown, ctx.locale)) { + const box = document.createElement('div') + box.className = 'sp-cal-cell' + (cell.inMonth ? '' : ' sp-cal-out') + box.dataset.day = cell.iso + if (cell.iso === today) { + box.classList.add('sp-cal-today') + box.title = t('Today') + } + const n = document.createElement('div') + n.className = 'sp-cal-num' + n.textContent = String(cell.day) + box.appendChild(n) + for (const row of split.days.get(cell.iso) ?? []) { + box.appendChild(pageLink(row, ctx.editable, 'sp-cal-entry')) + } + grid.appendChild(box) + } + body.appendChild(grid) +} + +function drawTimeline(body: HTMLElement, ctx: CalendarCtx, split: DateSplit): void { + const line = document.createElement('div') + line.className = 'sp-cal-line' + for (const iso of timelineDays(split.days)) { + const day = document.createElement('div') + day.className = 'sp-cal-day' + const h = document.createElement('div') + h.className = 'sp-cal-dayhead' + h.textContent = journalLabel(iso, ctx.locale) + day.appendChild(h) + for (const row of split.days.get(iso) ?? []) day.appendChild(ctx.card(row)) + line.appendChild(day) + } + body.appendChild(line) +} + +/** + * The pages the rule found no date for. + * + * VISIBLE, and that is the point. A calendar that quietly holds fewer pages + * than the count beside its own title is a view lying about what it contains — + * and the pages it drops are exactly the ones somebody forgot to date, which is + * the thing they most need to see. + */ +function drawUndated(body: HTMLElement, ctx: CalendarCtx, split: DateSplit): void { + if (!split.undated.length) return + const box = document.createElement('div') + box.className = 'sp-cal-undated' + const h = document.createElement('div') + h.className = 'sp-cal-dayhead' + h.textContent = `${t('No date')} · ${split.undated.length}` + box.appendChild(h) + const ul = document.createElement('div') + ul.className = 'sp-cal-undated-list' + for (const row of split.undated) ul.appendChild(pageLink(row, ctx.editable, 'sp-cal-entry')) + box.appendChild(ul) + body.appendChild(box) +} diff --git a/spaces/src/editor.ts b/spaces/src/editor.ts index e682b5b1..8a3b0a0e 100644 --- a/spaces/src/editor.ts +++ b/spaces/src/editor.ts @@ -30,6 +30,7 @@ import { cycleSort, nextLayout, type DropAim, type FieldSpec, type ViewFilter, type ViewSort, } from './fields' +import { nextSpan } from './calendar.ts' import { planImport, type SourceFile } from './markdown' import { extractSpace, planGraft } from './portable' import { countOutsideTags, replaceOutsideTags } from './findreplace' @@ -1599,6 +1600,8 @@ export class Editor { fb?.addEventListener('click', () => this.openViewFilter(v.dataset.blockId!, fb)) const lb = v.querySelector('[data-view-layout]') lb?.addEventListener('click', () => this.toggleViewLayout(v.dataset.blockId!)) + const spb = v.querySelector('[data-view-span]') + spb?.addEventListener('click', () => this.toggleViewSpan(v.dataset.blockId!)) const gb = v.querySelector('[data-view-group]') gb?.addEventListener('click', () => this.openViewGroup(v.dataset.blockId!, gb)) const sb = v.querySelector('[data-view-sort]') @@ -1796,7 +1799,7 @@ export class Editor { * list and back is byte-identical to one that was never touched, and a file * written before this control existed stays that way. */ - private editView(blockId: string, key: 'layout' | 'groupBy' | 'sort' | 'source', value: unknown): void { + private editView(blockId: string, key: 'layout' | 'groupBy' | 'sort' | 'source' | 'span', value: unknown): void { const s = this.store const b = s.block(blockId) if (!b || s.readOnly || this.reading) return @@ -1868,6 +1871,21 @@ export class Editor { this.editView(blockId, 'layout', to === 'board' ? undefined : to) } + /** + * MONTH ⇄ TIMELINE, the calendar's own second shape. + * + * A parameter of one layout, like `groupBy` — not a sixth entry in the layout + * cycle, whose cost is one click for everybody every time they pass it. Same + * writer discipline as every other view key: `month` is the DEFAULT, so it is + * stored as an ABSENT key and a view toggled to the timeline and back is + * byte-identical to one nobody touched. + */ + private toggleViewSpan(blockId: string): void { + const b = this.store.block(blockId) + const to = nextSpan((b as { span?: unknown } | undefined)?.span) + this.editView(blockId, 'span', to === 'month' ? undefined : to) + } + /** * Which field the columns come from. * diff --git a/spaces/src/fields.ts b/spaces/src/fields.ts index c71ea2ae..c85c6878 100644 --- a/spaces/src/fields.ts +++ b/spaces/src/fields.ts @@ -638,7 +638,7 @@ export function cycleSort(sort: unknown, key: string): ViewSort[] | undefined { * and source already follow. `nextLayout` returns the word; the caller that * WRITES is the one that turns 'board' back into a deletion. */ -export const VIEW_LAYOUTS = ['board', 'list', 'table', 'gallery'] as const +export const VIEW_LAYOUTS = ['board', 'list', 'table', 'gallery', 'calendar'] as const export type ViewLayout = (typeof VIEW_LAYOUTS)[number] /** @@ -656,7 +656,7 @@ export function layoutOf(raw: unknown): ViewLayout { return (VIEW_LAYOUTS as readonly string[]).includes(s) ? (s as ViewLayout) : 'board' } -/** The next shape in the cycle: board → list → table → gallery → board. */ +/** The next shape: board → list → table → gallery → calendar → board. */ export function nextLayout(raw: unknown): ViewLayout { const here = layoutOf(raw) return VIEW_LAYOUTS[(VIEW_LAYOUTS.indexOf(here) + 1) % VIEW_LAYOUTS.length] diff --git a/spaces/src/i18n/de.ts b/spaces/src/i18n/de.ts index 29921266..b3528944 100644 --- a/spaces/src/i18n/de.ts +++ b/spaces/src/i18n/de.ts @@ -629,4 +629,15 @@ export const de: Catalog = { "Date": "Datum", "Person": "Person", "Labels": "Labels", + "Calendar": "Kalender", + "Show as a calendar": "Als Kalender anzeigen", + "Timeline": "Zeitstrahl", + "Month": "Monat", + "Show a month at a time": "Einen Monat auf einmal anzeigen", + "Show a timeline": "Als Zeitstrahl anzeigen", + "Dated by the journal date.": "Datiert nach dem Journaldatum.", + "Dated by the journal date, then {fields}.": "Datiert nach dem Journaldatum, dann {fields}.", + "Previous month": "Vorheriger Monat", + "Next month": "Nächster Monat", + "No date": "Kein Datum", } diff --git a/spaces/src/i18n/es.ts b/spaces/src/i18n/es.ts index 772b824f..f8a30176 100644 --- a/spaces/src/i18n/es.ts +++ b/spaces/src/i18n/es.ts @@ -629,4 +629,15 @@ export const es: Catalog = { "Date": "Fecha", "Person": "Persona", "Labels": "Etiquetas", + "Calendar": "Calendario", + "Show as a calendar": "Ver como calendario", + "Timeline": "Cronología", + "Month": "Mes", + "Show a month at a time": "Mostrar un mes cada vez", + "Show a timeline": "Mostrar una cronología", + "Dated by the journal date.": "Fechado por la fecha del diario.", + "Dated by the journal date, then {fields}.": "Fechado por la fecha del diario, luego {fields}.", + "Previous month": "Mes anterior", + "Next month": "Mes siguiente", + "No date": "Sin fecha", } diff --git a/spaces/src/i18n/fr.ts b/spaces/src/i18n/fr.ts index a7042f28..d22e5d37 100644 --- a/spaces/src/i18n/fr.ts +++ b/spaces/src/i18n/fr.ts @@ -629,4 +629,15 @@ export const fr: Catalog = { "Date": "Date", "Person": "Personne", "Labels": "Étiquettes", + "Calendar": "Calendrier", + "Show as a calendar": "Afficher en calendrier", + "Timeline": "Chronologie", + "Month": "Mois", + "Show a month at a time": "Afficher un mois à la fois", + "Show a timeline": "Afficher une chronologie", + "Dated by the journal date.": "Daté par la date du journal.", + "Dated by the journal date, then {fields}.": "Daté par la date du journal, puis {fields}.", + "Previous month": "Mois précédent", + "Next month": "Mois suivant", + "No date": "Sans date", } diff --git a/spaces/src/i18n/it.ts b/spaces/src/i18n/it.ts index a4f1c6af..5c76199d 100644 --- a/spaces/src/i18n/it.ts +++ b/spaces/src/i18n/it.ts @@ -629,4 +629,15 @@ export const it: Catalog = { "Date": "Data", "Person": "Persona", "Labels": "Etichette", + "Calendar": "Calendario", + "Show as a calendar": "Mostra come calendario", + "Timeline": "Cronologia", + "Month": "Mese", + "Show a month at a time": "Mostra un mese alla volta", + "Show a timeline": "Mostra una cronologia", + "Dated by the journal date.": "Datato in base alla data del diario.", + "Dated by the journal date, then {fields}.": "Datato in base alla data del diario, poi {fields}.", + "Previous month": "Mese precedente", + "Next month": "Mese successivo", + "No date": "Senza data", } diff --git a/spaces/src/i18n/ja.ts b/spaces/src/i18n/ja.ts index 89841877..4e56b0d1 100644 --- a/spaces/src/i18n/ja.ts +++ b/spaces/src/i18n/ja.ts @@ -629,4 +629,15 @@ export const ja: Catalog = { "Date": "日付", "Person": "担当者", "Labels": "ラベル", + "Calendar": "カレンダー", + "Show as a calendar": "カレンダーで表示", + "Timeline": "タイムライン", + "Month": "月", + "Show a month at a time": "1か月ずつ表示", + "Show a timeline": "タイムラインで表示", + "Dated by the journal date.": "ジャーナルの日付で並べています。", + "Dated by the journal date, then {fields}.": "ジャーナルの日付、次に {fields} で並べています。", + "Previous month": "前の月", + "Next month": "次の月", + "No date": "日付なし", } diff --git a/spaces/src/i18n/packed.ts b/spaces/src/i18n/packed.ts index 2ed45fd6..30250ecb 100644 --- a/spaces/src/i18n/packed.ts +++ b/spaces/src/i18n/packed.ts @@ -78,6 +78,7 @@ export const PACKED: Record> = { "Bring notes in": ["ノートを取り込む","把笔记带进来","把筆記帶進來","Traer notas","Faire entrer des notes","Notizen hereinholen","Portare dentro le note","Trazer notas para cá"], "Brown": ["茶","棕色","棕色","Marrón","Marron","Braun","Marrone","Marrom"], "Bulleted list": ["箇条書き","项目符号列表","項目符號清單","Lista con viñetas","Liste à puces","Aufzählung","Elenco puntato","Lista com marcadores"], + "Calendar": ["カレンダー","日历","日曆","Calendario","Calendrier","Kalender","Calendario","Calendário"], "Callout": ["コールアウト","标注","標註","Aviso","Encadré","Hinweisblock","Riquadro","Destaque"], "Callout icon": ["囲みのアイコン","标注图标","標註圖示","Icono del aviso","Icône de l'encadré","Symbol des Hinweisfelds","Icona del riquadro","Ícone do destaque"], "Cancel": ["キャンセル","取消","取消","Cancelar","Annuler","Abbrechen","Annulla","Cancelar"], @@ -139,6 +140,8 @@ export const PACKED: Record> = { "Create “{name}”": ["「{name}」を作成","创建“{name}”","建立「{name}」","Crear «{name}»","Créer « {name} »","„{name}“ erstellen","Crea «{name}»","Criar “{name}”"], "Dark": ["ダーク","深色","深色","Oscuro","Sombre","Dunkel","Scuro","Escuro"], "Date": ["日付","日期","日期","Fecha","Date","Datum","Data","Data"], + "Dated by the journal date, then {fields}.": ["ジャーナルの日付、次に {fields} で並べています。","按日志日期排列,其次是 {fields}。","依日誌日期排列,其次是 {fields}。","Fechado por la fecha del diario, luego {fields}.","Daté par la date du journal, puis {fields}.","Datiert nach dem Journaldatum, dann {fields}.","Datato in base alla data del diario, poi {fields}.","Datado pela data do diário, depois {fields}."], + "Dated by the journal date.": ["ジャーナルの日付で並べています。","按日志日期排列。","依日誌日期排列。","Fechado por la fecha del diario.","Daté par la date du journal.","Datiert nach dem Journaldatum.","Datato in base alla data del diario.","Datado pela data do diário."], "Default": ["デフォルト","默认","預設","Predeterminado","Par défaut","Standard","Predefinito","Padrão"], "Delete": ["削除","删除","刪除","Eliminar","Supprimer","Löschen","Elimina","Eliminar"], "Delete “{name}”?": ["「{name}」を削除しますか?","要删除“{name}”吗?","要刪除「{name}」嗎?","¿Eliminar «{name}»?","Supprimer « {name} » ?","„{name}“ löschen?","Eliminare «{name}»?","Eliminar “{name}”?"], @@ -274,6 +277,7 @@ export const PACKED: Record> = { "Manual order": ["手動の並び順","手动排序","手動排序","Orden manual","Ordre manuel","Manuelle Reihenfolge","Ordine manuale","Ordem manual"], "Match my system": ["システムに合わせる","跟随系统","跟隨系統","Según el sistema","Comme le système","Systemeinstellung folgen","Come il sistema","Acompanhar o sistema"], "Mints brand-new keys. Every previously sent copy stops syncing for good; share fresh copies afterwards.": ["まったく新しい鍵を発行します。これまでに送ったコピーは永久に同期しなくなります。以後は新しいコピーを共有してください。","铸造全新密钥。之前发送的所有副本将永久停止同步;此后请分享新副本。","鑄造全新金鑰。之前傳送的所有副本將永久停止同步;此後請分享新副本。","Genera claves totalmente nuevas. Todas las copias enviadas dejan de sincronizarse para siempre; comparte copias nuevas después.","Génère des clés toutes neuves. Chaque copie déjà envoyée cesse définitivement de se synchroniser ; partagez ensuite de nouvelles copies.","Erzeugt brandneue Schlüssel. Jede bereits gesendete Kopie synchronisiert endgültig nicht mehr; teile danach frische Kopien.","Genera chiavi nuove di zecca. Ogni copia già inviata smette per sempre di sincronizzarsi; condividi poi copie nuove.","Gera chaves novas em folha. Toda cópia enviada antes para de sincronizar de vez; compartilhe cópias novas depois."], + "Month": ["月","月","月","Mes","Mois","Monat","Mese","Mês"], "More": ["その他","更多","更多","Más","Plus","Mehr","Altro","Mais"], "Move down": ["下へ移動","下移","下移","Bajar","Descendre","Nach unten","Sposta giù","Mover para baixo"], "Move up": ["上へ移動","上移","上移","Subir","Monter","Nach oben","Sposta su","Mover para cima"], @@ -288,8 +292,10 @@ export const PACKED: Record> = { "New property": ["新しいプロパティ","新建属性","新增屬性","Nueva propiedad","Nouvelle propriété","Neue Eigenschaft","Nuova proprietà","Nova propriedade"], "New to Bento? Find templates, the gallery and the AI editing guide at {home} — or ⭐ it on {gh}.": ["Bento は初めてですか?テンプレート、ギャラリー、AI編集ガイドは {home} でどうぞ — {gh} で ⭐ もぜひ。","第一次用 Bento?在 {home} 查看模板、图库和 AI 编辑指南 — 也欢迎到 {gh} 点 ⭐。","第一次用 Bento?在 {home} 查看範本、圖庫和 AI 編輯指南 — 也歡迎到 {gh} 按 ⭐。","¿Nuevo en Bento? Encuentra plantillas, la galería y la guía de edición con IA en {home} — o dale ⭐ en {gh}.","Nouveau sur Bento ? Trouvez des modèles, la galerie et le guide d’édition par IA sur {home} — ou mettez une ⭐ sur {gh}.","Neu bei Bento? Vorlagen, die Galerie und den KI-Bearbeitungsleitfaden findest du auf {home} — oder gib ⭐ auf {gh}.","Nuovo su Bento? Trovi modelli, la galleria e la guida all’editing con IA su {home} — o metti una ⭐ su {gh}.","Novo no Bento? Encontre modelos, a galeria e o guia de edição com IA em {home} — ou dê uma ⭐ no {gh}."], "Next (⏎)": ["次へ (⏎)","下一个 (⏎)","下一個 (⏎)","Siguiente (⏎)","Suivant (⏎)","Weiter (⏎)","Successivo (⏎)","Seguinte (⏎)"], + "Next month": ["次の月","下一月","下一月","Mes siguiente","Mois suivant","Nächster Monat","Mese successivo","Próximo mês"], "No Markdown files in that selection": ["選択に Markdown ファイルがありません","所选内容里没有 Markdown 文件","所選項目中沒有 Markdown 檔案","No hay archivos Markdown en esa selección","Aucun fichier Markdown dans cette sélection","Keine Markdown-Dateien in dieser Auswahl","Nessun file Markdown in questa selezione","Não há ficheiros Markdown nessa seleção"], "No block matches": ["一致するブロックがありません","没有匹配的块","沒有符合的區塊","Ningún bloque coincide","Aucun bloc correspondant","Kein Block passt","Nessun blocco corrisponde","Nenhum bloco corresponde"], + "No date": ["日付なし","无日期","無日期","Sin fecha","Sans date","Kein Datum","Senza data","Sem data"], "No field here has options to group by": ["グループ化に使える選択肢を持つフィールドがありません","这里没有可用于分组的选项字段","這裡沒有可用於分組的選項欄位","Ningún campo de aquí tiene opciones para agrupar","Aucun champ ici n’a d’options pour grouper","Kein Feld hier hat Optionen zum Gruppieren","Nessun campo qui ha opzioni per raggruppare","Nenhum campo aqui tem opções para agrupar"], "No issues match this filter.": ["この条件に一致するイシューはありません。","没有符合此筛选条件的事项。","沒有符合此篩選條件的項目。","Ninguna incidencia coincide con este filtro.","Aucun ticket ne correspond à ce filtre.","Keine Issues entsprechen diesem Filter.","Nessuna issue corrisponde a questo filtro.","Nenhuma tarefa corresponde a este filtro."], "No issues yet. Add a status field to any page and it appears here.": ["まだイシューはありません。どれかのページにステータス項目を追加すると、ここに表示されます。","还没有事项。给任意页面添加状态字段,它就会出现在这里。","還沒有項目。為任一頁面新增狀態欄位,它就會出現在這裡。","Aún no hay incidencias. Añade un campo de estado a cualquier página y aparecerá aquí.","Aucun ticket pour l’instant. Ajoutez un champ statut à une page et elle apparaîtra ici.","Noch keine Issues. Gib einer Seite ein Status-Feld, dann erscheint sie hier.","Ancora nessuna issue. Aggiungi un campo stato a una pagina e comparirà qui.","Ainda não há tarefas. Adiciona um campo de estado a qualquer página e ela aparece aqui."], @@ -347,6 +353,7 @@ export const PACKED: Record> = { "Poster": ["ポスター","封面图","封面圖","Póster","Affiche","Poster","Poster","Miniatura"], "Poster…": ["ポスター…","封面图…","封面圖…","Póster…","Affiche…","Poster…","Poster…","Miniatura…"], "Previous (⇧⏎)": ["前へ (⇧⏎)","上一个 (⇧⏎)","上一個 (⇧⏎)","Anterior (⇧⏎)","Précédent (⇧⏎)","Zurück (⇧⏎)","Precedente (⇧⏎)","Anterior (⇧⏎)"], + "Previous month": ["前の月","上一月","上一月","Mes anterior","Mois précédent","Vorheriger Monat","Mese precedente","Mês anterior"], "Print": ["印刷","打印","列印","Imprimir","Imprimer","Drucken","Stampa","Imprimir"], "Print or save as PDF": ["印刷または PDF として保存","打印或存为 PDF","列印或另存為 PDF","Imprimir o guardar como PDF","Imprimer ou enregistrer en PDF","Drucken oder als PDF speichern","Stampa o salva come PDF","Imprimir ou guardar como PDF"], "Print…": ["印刷…","打印…","列印…","Imprimir…","Imprimer…","Drucken…","Stampa…","Imprimir…"], @@ -415,7 +422,10 @@ export const PACKED: Record> = { "Select": ["選択","单选","單選","Selección","Sélection","Auswahl","Selezione","Seleção"], "Set a password…": ["パスワードを設定…","设置密码…","設定密碼…","Establecer una contraseña…","Définir un mot de passe…","Passwort festlegen…","Imposta una password…","Definir uma palavra-passe…"], "Share this space": ["このスペースを共有","共享此空间","分享此空間","Compartir este espacio","Partager cet espace","Diesen Space teilen","Condividi questo spazio","Partilhar este espaço"], + "Show a month at a time": ["1か月ずつ表示","按月显示","按月顯示","Mostrar un mes cada vez","Afficher un mois à la fois","Einen Monat auf einmal anzeigen","Mostra un mese alla volta","Mostrar um mês de cada vez"], + "Show a timeline": ["タイムラインで表示","以时间线显示","以時間軸顯示","Mostrar una cronología","Afficher une chronologie","Als Zeitstrahl anzeigen","Mostra una cronologia","Mostrar uma linha do tempo"], "Show as a board": ["ボードで表示","以看板显示","以看板顯示","Mostrar como tablero","Afficher en tableau","Als Board anzeigen","Mostra come bacheca","Mostrar como quadro"], + "Show as a calendar": ["カレンダーで表示","以日历显示","以日曆顯示","Ver como calendario","Afficher en calendrier","Als Kalender anzeigen","Mostra come calendario","Ver como calendário"], "Show as a gallery": ["ギャラリーで表示","以图库显示","以圖庫顯示","Mostrar como galería","Afficher en galerie","Als Galerie anzeigen","Mostra come galleria","Mostrar como galeria"], "Show as a list": ["リストで表示","以列表显示","以清單顯示","Mostrar como lista","Afficher en liste","Als Liste anzeigen","Mostra come elenco","Mostrar como lista"], "Show as a table": ["テーブルで表示","以表格显示","以表格顯示","Mostrar como tabla","Afficher en tableau","Als Tabelle anzeigen","Mostra come tabella","Mostrar como tabela"], @@ -500,6 +510,7 @@ export const PACKED: Record> = { "This window is still running v{v}. If you overwrote the file that's open here, reload; otherwise open the file you saved.": ["このウィンドウはまだ v{v} で動作中。開いているファイルを上書きした場合は再読込を。別に保存した場合はそのファイルを開いてください。","此窗口仍在运行 v{v}。若覆盖了当前打开的文件请重新加载;否则请打开您保存的文件。","此視窗仍在執行 v{v}。若已覆寫目前開啟的檔案請重新載入;否則請開啟您儲存的檔案。","Esta ventana sigue en v{v}. Si sobrescribiste el archivo abierto aquí, recarga; si no, abre el archivo que guardaste.","Cette fenêtre tourne encore en v{v}. Si vous avez écrasé le fichier ouvert ici, rechargez ; sinon ouvrez le fichier enregistré.","Dieses Fenster läuft noch mit v{v}. Wenn Sie die geöffnete Datei überschrieben haben, neu laden; andernfalls die gespeicherte Datei öffnen.","Questa finestra esegue ancora la v{v}. Se hai sovrascritto il file aperto qui, ricarica; altrimenti apri il file salvato.","Esta janela ainda está executando a v{v}. Se você sobrescreveu o arquivo aberto aqui, recarregue; caso contrário, abra o arquivo que salvou."], "This window keeps running v{v} until you open the downloaded file.": ["ダウンロードしたファイルを開くまで、このウィンドウは v{v} のままです。","在打开下载的文件之前,此窗口将继续运行 v{v}。","在開啟下載的檔案之前,此視窗會維持在 v{v}。","Esta ventana seguirá en v{v} hasta que abras el archivo descargado.","Cette fenêtre reste en v{v} jusqu'à l'ouverture du fichier téléchargé.","Dieses Fenster bleibt bei v{v}, bis Sie die heruntergeladene Datei öffnen.","Questa finestra resta alla v{v} finché non apri il file scaricato.","Esta janela continua executando a v{v} até você abrir o arquivo baixado."], "Those files could not be read": ["それらのファイルは読み込めませんでした","这些文件无法读取","這些檔案無法讀取","No se pudieron leer esos archivos","Ces fichiers n’ont pas pu être lus","Diese Dateien konnten nicht gelesen werden","Non è stato possibile leggere quei file","Não foi possível ler esses ficheiros"], + "Timeline": ["タイムライン","时间线","時間軸","Cronología","Chronologie","Zeitstrahl","Cronologia","Linha do tempo"], "Tip": ["ヒント","提示","提示","Consejo","Astuce","Tipp","Suggerimento","Dica"], "Title": ["タイトル","标题","標題","Título","Titre","Titel","Titolo","Título"], "To-do": ["チェックリスト","待办事项","待辦事項","Tarea","Tâche","Aufgabe","Attività","Tarefa"], diff --git a/spaces/src/i18n/pt.ts b/spaces/src/i18n/pt.ts index 0e26b5a6..80ffb4b4 100644 --- a/spaces/src/i18n/pt.ts +++ b/spaces/src/i18n/pt.ts @@ -629,4 +629,15 @@ export const pt: Catalog = { "Date": "Data", "Person": "Pessoa", "Labels": "Etiquetas", + "Calendar": "Calendário", + "Show as a calendar": "Ver como calendário", + "Timeline": "Linha do tempo", + "Month": "Mês", + "Show a month at a time": "Mostrar um mês de cada vez", + "Show a timeline": "Mostrar uma linha do tempo", + "Dated by the journal date.": "Datado pela data do diário.", + "Dated by the journal date, then {fields}.": "Datado pela data do diário, depois {fields}.", + "Previous month": "Mês anterior", + "Next month": "Próximo mês", + "No date": "Sem data", } diff --git a/spaces/src/i18n/zh-Hans.ts b/spaces/src/i18n/zh-Hans.ts index f97f50a2..17117ce8 100644 --- a/spaces/src/i18n/zh-Hans.ts +++ b/spaces/src/i18n/zh-Hans.ts @@ -629,4 +629,15 @@ export const zh_Hans: Catalog = { "Date": "日期", "Person": "人员", "Labels": "标签", + "Calendar": "日历", + "Show as a calendar": "以日历显示", + "Timeline": "时间线", + "Month": "月", + "Show a month at a time": "按月显示", + "Show a timeline": "以时间线显示", + "Dated by the journal date.": "按日志日期排列。", + "Dated by the journal date, then {fields}.": "按日志日期排列,其次是 {fields}。", + "Previous month": "上一月", + "Next month": "下一月", + "No date": "无日期", } diff --git a/spaces/src/i18n/zh-Hant.ts b/spaces/src/i18n/zh-Hant.ts index 85526d58..c6eafb0b 100644 --- a/spaces/src/i18n/zh-Hant.ts +++ b/spaces/src/i18n/zh-Hant.ts @@ -629,4 +629,15 @@ export const zh_Hant: Catalog = { "Date": "日期", "Person": "人員", "Labels": "標籤", + "Calendar": "日曆", + "Show as a calendar": "以日曆顯示", + "Timeline": "時間軸", + "Month": "月", + "Show a month at a time": "按月顯示", + "Show a timeline": "以時間軸顯示", + "Dated by the journal date.": "依日誌日期排列。", + "Dated by the journal date, then {fields}.": "依日誌日期排列,其次是 {fields}。", + "Previous month": "上一月", + "Next month": "下一月", + "No date": "無日期", } diff --git a/spaces/src/render.ts b/spaces/src/render.ts index 8d5c05d3..6c8de1b5 100644 --- a/spaces/src/render.ts +++ b/spaces/src/render.ts @@ -20,6 +20,7 @@ import { sortRows, unknownSortKeys, sortDirOf, layoutOf, nextLayout, type ViewSort, type FieldSpec, type ViewLayout, } from './fields' +import { renderCalendar, spanOf, nextSpan } from './calendar.ts' import { answer, feed, freshContext, type CalcCtx } from './calc.ts' import { ICONS, type IconName } from './icons' import { renderCanvasHead, placeCard } from './canvas.ts' @@ -1082,6 +1083,7 @@ function pageMark(host: HTMLElement, page: Page): void { */ function renderView(host: HTMLElement, b: Block, doc: SpacesDoc, opts: RenderOpts): void { const layout = String((b as { layout?: unknown }).layout ?? 'board') + const calSpan = (b as { span?: unknown }).span const groupKey = String((b as { groupBy?: unknown }).groupBy ?? 'status') const field = fieldByKey(doc, groupKey) const filter = (b as { filter?: unknown }).filter @@ -1146,14 +1148,27 @@ function renderView(host: HTMLElement, b: Block, doc: SpacesDoc, opts: RenderOpt // fields.ts answers "which shape is this" and "what comes next". const LAYOUT_LABEL: Record = { board: t('Board'), list: t('List'), table: t('Table'), gallery: t('Gallery'), + calendar: t('Calendar'), } const NEXT_LABEL: Record = { board: t('Show as a list'), list: t('Show as a table'), - table: t('Show as a gallery'), gallery: t('Show as a board'), + table: t('Show as a gallery'), gallery: t('Show as a calendar'), + calendar: t('Show as a board'), } const layoutB = btn('viewLayout', LAYOUT_LABEL[here], NEXT_LABEL[here]) layoutB.dataset.next = nextLayout(here) + // THE CALENDAR'S SECOND SHAPE, on the pattern `groupBy` already set: a + // parameter of ONE layout gets its own control, shown only while that + // layout is on, rather than a sixth entry in a cycle everybody has to click + // through. Whole sentences again, for the reason three lines up. + const spanB = here === 'calendar' + ? btn('viewSpan', + spanOf(calSpan) === 'timeline' ? t('Timeline') : t('Month'), + spanOf(calSpan) === 'timeline' ? t('Show a month at a time') : t('Show a timeline')) + : undefined + if (spanB) spanB.dataset.next = nextSpan(calSpan) + // GROUP BY. Only fields with declared options: a board's columns ARE the // option list, so grouping by a free-text field would make one column per // distinct string and call it a board. @@ -1188,7 +1203,8 @@ function renderView(host: HTMLElement, b: Block, doc: SpacesDoc, opts: RenderOpt const sourceB = btn('viewSource', `${t('Pages')} · ${srcLabel}`, t('Choose which pages this view holds'), !!(hasKey || underId)) - head.append(layoutB, sourceB, ...(asList ? [] : [groupB]), sortB, openB, filterB) + head.append(layoutB, ...(spanB ? [spanB] : []), sourceB, + ...(asList ? [] : [groupB]), sortB, openB, filterB) } host.appendChild(head) @@ -1556,6 +1572,24 @@ function renderView(host: HTMLElement, b: Block, doc: SpacesDoc, opts: RenderOpt return } + // CALENDAR — the shape that answers "when". Its own file: the arithmetic is + // the part of this app most likely to be wrong east of UTC, and it wanted a + // rig that can import it without a DOM. `layoutOf`, not the raw string, so a + // block claiming `layout:"toString"` cannot reach this branch and everything + // a newer build might name falls through to the board. + if (layoutOf(layout) === 'calendar') { + renderCalendar(host, { + doc, + rows, + blockId: b.id, + span: spanOf(calSpan), + locale: locale(), + editable: opts.editable, + card: (r) => card(r.page, r.values), + }) + return + } + if (layout === 'list') { const ul = document.createElement('ul') ul.className = 'sp-view-list' diff --git a/spaces/src/styles.css b/spaces/src/styles.css index ce524af5..5bde5b77 100644 --- a/spaces/src/styles.css +++ b/spaces/src/styles.css @@ -2383,3 +2383,60 @@ body.sp-canvas-dragging { user-select: none; cursor: grabbing; } .sp-canvas-body { background: none; background-image: none; } .sp-cv-grip, .sp-canvas-btn { display: none !important; } } + +/* ---- the calendar layout ------------------------------------------------- + A GRID OF SEVEN COLUMNS, and the number of ROWS is never asserted here: a + month is four, five or six weeks depending on the month and on where the + reader's week starts, so `grid-auto-rows` lets the cells the renderer emitted + decide. `repeat(6, …)` would clip a six-week month's last week silently. */ +.sp-cal-hint { color: var(--muted); font-size: 12px; margin: 0 0 8px; } +.sp-cal-bar { display: flex; align-items: center; gap: 6px; margin: 0 0 8px; } +.sp-cal-month { font-weight: 600; font-size: 14px; min-width: 8.5em; text-align: center; } +.sp-cal-step { padding: 2px 8px; } +.sp-cal-grid { + display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 1px; background: var(--line); border: 1px solid var(--line); + border-radius: 8px; overflow: hidden; margin-bottom: 4px; +} +.sp-cal-wd { + background: var(--chrome); color: var(--muted); + font-size: 11px; font-weight: 600; text-align: center; padding: 5px 2px; +} +.sp-cal-cell { + background: var(--surface); min-height: 76px; padding: 4px 5px 6px; + display: flex; flex-direction: column; gap: 2px; min-width: 0; +} +/* the neighbouring months' days are drawn, not hollowed out: a grid whose + corners are holes reads as broken, and an entry on the 1st of next month is + what somebody looking at the last week of this one wants to see */ +.sp-cal-out { background: var(--chrome-2, var(--chrome)); } +.sp-cal-out .sp-cal-num { opacity: .45; } +.sp-cal-today { box-shadow: inset 0 0 0 2px var(--accent, var(--edge)); } +.sp-cal-today .sp-cal-num { color: var(--accent, var(--ink)); font-weight: 700; } +.sp-cal-num { font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; } +.sp-cal-entry { + display: block; font-size: 12px; line-height: 1.3; color: var(--ink); + text-decoration: none; background: var(--chrome); border-radius: 4px; + padding: 2px 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.sp-cal-entry:hover { background: var(--edge, var(--chrome)); text-decoration: underline; } +.sp-cal-day { margin: 0 0 14px; } +.sp-cal-dayhead { + font-size: 12px; font-weight: 600; color: var(--muted); + padding-bottom: 4px; margin-bottom: 6px; border-bottom: 1px solid var(--line); +} +.sp-cal-undated { margin-top: 14px; } +.sp-cal-undated-list { display: flex; flex-wrap: wrap; gap: 5px; } +.sp-cal-undated-list .sp-cal-entry { max-width: 220px; } +/* A PHONE CANNOT SHOW SEVEN READABLE COLUMNS of entries, but it can show the + shape of the month. The cells shrink and the entries become dots-with-a-tail + rather than disappearing — an empty-looking calendar is worse than a cramped + one, because it is wrong. */ +@media (max-width: 640px) { + .sp-cal-cell { min-height: 54px; padding: 3px 2px 4px; } + .sp-cal-entry { font-size: 10px; padding: 1px 3px; } +} +@media print { + /* the arrows navigate nothing on paper */ + .sp-cal-bar .sp-cal-step { display: none; } +}