diff --git a/apps/web/__tests__/DecisionsView.test.tsx b/apps/web/__tests__/DecisionsView.test.tsx index 378b5ba19..32f4dc7ae 100644 --- a/apps/web/__tests__/DecisionsView.test.tsx +++ b/apps/web/__tests__/DecisionsView.test.tsx @@ -14,7 +14,7 @@ vi.mock('../src/hooks/useDecisions.js', () => ({ useDecisions: (isActive: boolean) => mockUseDecisions(isActive), })); -import { DecisionsView, STAMP_FORMAT } from '../src/components/DecisionsView.js'; +import { DecisionsView, STAMP_FORMAT, STAMP_TIME_FORMAT } from '../src/components/DecisionsView.js'; /** The viewer's zone for every test here: neither the stamps' zone (PT) nor UTC. */ const VIEWER_TZ = 'America/New_York'; @@ -171,7 +171,6 @@ describe('DecisionsView card stamps', () => { }); it.each([ - ['unrecognized-format', '2026-10-17 25:61 PT', 'Not converted: the date or time is not in a recognized format'], ['unknown-zone', '2026-10-17 1:30 am IST', 'Not converted: the time zone is not one the tab recognizes'], ['nonexistent-time', '2026-03-08 2:30 am PT', 'Not converted: that time does not exist on that date in that zone (the clocks skipped it)'], ['ambiguous-time', '2026-11-01 1:30 am PT', 'Not converted: that time happens twice on that date in that zone (the clocks went back)'], @@ -182,6 +181,24 @@ describe('DecisionsView card stamps', () => { expect(c.querySelector('time')).toBeNull(); }); + it('shows a time-only stamp as the converted time alone, with no date (#1836)', () => { + const instant = '2026-10-20T01:30:00.000Z'; + showItems(makeItem('main-12', 'Q', { stamp: { raw: '6:30 pm', instant, timeOnly: true } })); + const time = within(card('Q')).getByText((_, el) => el?.tagName === 'TIME'); + const timeAlone = new Intl.DateTimeFormat(undefined, { ...STAMP_TIME_FORMAT, timeZone: VIEWER_TZ }).format(new Date(instant)); + expect(time.textContent).toBe(timeAlone); + expect(time.textContent).not.toMatch(/Oct|2026/); + expect(time).toHaveAttribute('title', '6:30 pm'); + }); + + it('shows a stamp with no zone known as written, with no warning (#1836)', () => { + showItems(makeItem('main-12', 'Q', { stamp: { raw: '6:30 pm', instant: null, timeOnly: true } })); + const c = card('Q'); + expect(c.querySelector('.decisions-stamp')).toHaveTextContent(/^6:30 pm$/); + expect(c).not.toHaveTextContent('Not converted'); + expect(c.querySelector('.decisions-stamp-flag')).toBeNull(); + }); + it('shows no stamp for a heading without one', () => { showItems(makeItem('main-12', 'Q')); const c = card('Q'); diff --git a/apps/web/src/components/DecisionsView.tsx b/apps/web/src/components/DecisionsView.tsx index 1a32560b0..aa6b5d528 100644 --- a/apps/web/src/components/DecisionsView.tsx +++ b/apps/web/src/components/DecisionsView.tsx @@ -76,7 +76,6 @@ const ITEM_PROBLEM_TEXT: Record = { }; const UNCONVERTED_TEXT: Record, string> = { - 'unrecognized-format': 'the date or time is not in a recognized format', 'unknown-zone': 'the time zone is not one the tab recognizes', 'nonexistent-time': 'that time does not exist on that date in that zone (the clocks skipped it)', 'ambiguous-time': 'that time happens twice on that date in that zone (the clocks went back)', @@ -95,6 +94,13 @@ export const STAMP_FORMAT: Intl.DateTimeFormatOptions = { timeZoneName: 'short', }; +/** A stamp written without a date: the time alone, so the tab never shows a date nobody wrote. */ +export const STAMP_TIME_FORMAT: Intl.DateTimeFormatOptions = { + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short', +}; + const EXTERNAL_LINK_RE = /^https?:\/\//i; /** @@ -202,15 +208,16 @@ function Stamp({ stamp }: { stamp: DecisionStamp }) { if (stamp.instant) { return ( ); } + if (!stamp.unconvertedReason) return {stamp.raw}; return ( {stamp.raw} - Not converted{stamp.unconvertedReason && `: ${UNCONVERTED_TEXT[stamp.unconvertedReason]}`} + Not converted: {UNCONVERTED_TEXT[stamp.unconvertedReason]} ); diff --git a/codev-skeleton/resources/decision-protocol.md b/codev-skeleton/resources/decision-protocol.md index 4981fb23f..484c9124b 100644 --- a/codev-skeleton/resources/decision-protocol.md +++ b/codev-skeleton/resources/decision-protocol.md @@ -30,7 +30,7 @@ and never reuses a number. ## 2. The item ```markdown -### - · · +### - · · - **Problem:** (or **Context:**) what is wrong or undecided, as the owner would see it. One to three sentences. - **Root cause:** why, for a fix. @@ -44,8 +44,10 @@ and never reuses a number. **Your word:** `` / `` / `hold` ``` -Delete labels that do not apply; never rename them. The hub bounces a malformed item back, -naming what is missing, and does not post it. +The stamp is when the item was written, in UTC on the 24-hour clock, as +`date -u '+%Y-%m-%d %H:%M UTC'` prints it; the tab shows it in each viewer's own time. Delete +labels that do not apply; never rename them. The hub bounces a malformed item back, naming +what is missing, and does not post it. ### What the tab reads @@ -56,17 +58,26 @@ per option and a comment box. heading, so inside an item use `####` or bold labels, never a higher heading. The file has exactly one `## Open`. - **The heading is id · stamp · question.** The first word is the id; ` · ` (space, middle dot, - space) separates the id, the stamp and the question. Open items that share an id are - read-only until the ids differ. -- **The stamp carries its zone; nothing assumes one.** `YYYY-MM-DD h:mm am` (or `pm`), or 24-hour - `YYYY-MM-DD HH:mm`, then the zone the time was read in. The tab converts the stamp using only - that zone and shows it in the viewer's local time. It reads the North-American generic zones - `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` (daylight saving follows the stamp's date), fixed - abbreviations such as `UTC`, `GMT`, `EST`/`EDT`, `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` - is North-American Central), offsets such as `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names - such as `Europe/Berlin`. Any other zone, including ambiguous abbreviations such as `IST`, - `BST` and `AST`, is shown as written and marked not converted, as is a time the clocks - skipped or repeated that day. + space) separates the id, the stamp and the question. A heading without a stamp is id · + question, and a first part the tab cannot read as a stamp stays in the question, shown as + written. Open items that share an id are read-only until the ids differ. +- **The stamp carries its zone; nothing assumes one.** Agents write it as `YYYY-MM-DD HH:mm UTC`, + 24-hour and in UTC (section 2). The tab converts the stamp using only the zone written in it + and shows its date and time in the viewer's local time. For anything a person types it also + reads `YYYY-MM-DD h:mm am` (or `pm`), or 24-hour `YYYY-MM-DD HH:mm`, then the zone the time + was read in: the North-American generic zones `PT`, `MT`, `CT`, `ET`, `AKT` and `HT` + (daylight saving follows the stamp's date), fixed abbreviations such as `UTC`, `GMT`, + `EST`/`EDT`, `CET`/`CEST`, `JST` and `AEST`/`AEDT` (`CST` is North-American Central), offsets + such as `+05:30`, `UTC+5:30` or `GMT-3`, and IANA names such as `Europe/Berlin`. Any other + zone, including ambiguous abbreviations such as `IST`, `BST` and `AST`, is shown as written + and marked not converted, as is a time the clocks skipped or repeated that day. +- **A file may name its zone once.** A line `_Zone: _` above `## Open`, from the same + list of zones, is the zone of every stamp that names none; the file template's is + `_Zone: UTC_` (section 4). A bare time, `h:mm am` or `HH:mm`, is read as today in that zone + when the tab reads the file, and shown as the time alone: as written, converted to your + clock, with no date because none was written. Without a file zone, a stamp that names no + zone is shown as written. A bare time is always read as today, so one written before + midnight and read after it lands on the wrong day: the full form stays the one to write. - **Exactly one `**Your word:**` line**, starting a line, outside a code block. The options are the backticked words on it, separated by ` / `. The first is the recommended one, and the tab marks it. A description follows a word after ` = ` and runs to the next ` / ` before a @@ -85,10 +96,23 @@ per option and a comment box. hand or written by the tab, the card shows it as the current ruling and takes no new submit. - **`## Ruled`** is shown read-only, as written. +In a file that names its zone, a bare time converts, and a heading without a stamp is an id +and a question (the item bodies are left out here): + +```markdown +_Zone: UTC_ + +## Open + +### web-6 · 01:40 · Should the export keep the old column names? + +### web-5 · Should the nightly report move to the morning? +``` + A worked example, a choice with bare words on the word line: ```markdown -### web-4 · 2026-10-17 9:40 pm ET · When a session expires mid-edit, should the login fix keep the page or sign the user out? +### web-4 · 2026-10-18 01:40 UTC · When a session expires mid-edit, should the login fix keep the page or sign the user out? - **Context:** users whose session expires while they edit lose their draft when the page reloads. - **Options:** (a) `retry` = refresh the sign-in in the background and keep the page (b) `signout` = sign the user out and keep the draft on this device @@ -103,7 +127,8 @@ A worked example, a choice with bare words on the word line: 1. **Post.** The hub puts the item at the top of `## Open`, updates the status line (`N open — `), and tells the owner in one chat line: - `New decision from : — `, plus `[urgent]` if marked. + `New decision from : — `, plus `[urgent]` if marked. The item's + stamp is in UTC (section 2), and the file's `_Zone: UTC_` line stays UTC (section 4). 2. **Learn of rulings.** The owner rules in the tab, by typing in the file, or in chat. A ruling made in the tab reaches the hub as a message from Tower: `RULED : `, or `QUESTION :` when no word was chosen, with the comment on the lines after it. The notice @@ -114,20 +139,22 @@ A worked example, a choice with bare words on the word line: Either way the hub scans every open item for text on the lines under its `**Your word:**` line and replies with one line per ruling found. A word in chat counts the same. 3. **Relay and record.** For each ruling the hub relays at once, - `afx send architect: 'RULING : (owner,