From 93505e183137a419fe81d2ce61e37d3ab90cc3d0 Mon Sep 17 00:00:00 2001 From: Eyal Delarea <23456142+EyalDelarea@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:45:02 +0300 Subject: [PATCH 1/5] feat(ui): add People + agenda view-logic (lib/agenda.js) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, DOM-free selectors over the /api/people, /api/meetings and /api/todos payloads so the §5/§6 screens stay thin: - initials / hueFromName / avatarTint — per-name oklch tinted disc - peopleStatusMeta — Hebrew label + warn flag (cold-lead → warn) - groupMeetingsByDay / relativeDay — UTC-day buckets, chronological, null-start meetings collected into a trailing bucket - eventDaySet / buildMonthGrid — calendar event dots + month grid - todoProgress — checklist done/open/pct Date math is over the UTC calendar day so grouping + the grid are deterministic and time-zone independent. Colocated test (19 cases). Co-Authored-By: Claude Opus 4.8 --- src/web/public/lib/agenda.js | 203 ++++++++++++++++++++++++++++++ src/web/public/lib/agenda.test.js | 156 +++++++++++++++++++++++ 2 files changed, 359 insertions(+) create mode 100644 src/web/public/lib/agenda.js create mode 100644 src/web/public/lib/agenda.test.js diff --git a/src/web/public/lib/agenda.js b/src/web/public/lib/agenda.js new file mode 100644 index 0000000..adbf409 --- /dev/null +++ b/src/web/public/lib/agenda.js @@ -0,0 +1,203 @@ +// ── People + Meetings/To-dos view-logic (pure) ────────── +// +// Deterministic helpers over the /api/people, /api/meetings and /api/todos +// payloads, kept out of the DOM layer so they can be unit-tested. The People +// (§5) and Meetings & To-dos (§6) screens in app.js just assemble markup from +// the structures these functions return. +// +// All date math is done over the UTC calendar day (the leading `YYYY-MM-DD` of +// each ISO string) so grouping + the month grid are fully deterministic and +// time-zone independent. + +// ── Avatar tint (shared by People rows + Meeting owners) ── + +/** + * Initials for a per-name tinted disc: first letter of up to the first two + * words, skipping leading punctuation (e.g. "אבי (קבלן)" → "אק"). + * @param {string} name + * @returns {string} + */ +export function initials(name) { + const words = String(name ?? "").trim().split(/\s+/).filter(Boolean).slice(0, 2); + return words + .map((w) => { + const letter = [...w].find((ch) => /\p{L}|\p{N}/u.test(ch)); + return letter ?? ""; + }) + .join(""); +} + +/** + * Stable hue (0–359) hashed from a name, so the same person always gets the + * same tint. A small FNV-ish rolling hash over code units. + * @param {string} name + * @returns {number} + */ +export function hueFromName(name) { + const s = String(name ?? ""); + let h = 0; + for (let i = 0; i < s.length; i++) { + h = (h * 31 + s.charCodeAt(i)) >>> 0; + } + return h % 360; +} + +/** + * Avatar tint for a name: a light disc background + a saturated ink, both in + * oklch keyed off the name's hash hue (mirrors the prototype `Avatar`). + * @param {string} name + * @returns {{ initials: string, hue: number, bg: string, fg: string }} + */ +export function avatarTint(name) { + const hue = hueFromName(name); + return { + initials: initials(name), + hue, + bg: `oklch(0.93 0.045 ${hue})`, + fg: `oklch(0.42 0.09 ${hue})`, + }; +} + +// ── People status ──────────────────────────────────────── + +/** + * Hebrew label + warn flag for a person's status. `cold-lead` is the only warn + * status (→ `--warn` badge). Unknown statuses fall back to the raw value. + * @param {string} status + * @returns {{ label: string, warn: boolean }} + */ +export function peopleStatusMeta(status) { + switch (status) { + case "cold-lead": + return { label: "ליד מתקרר", warn: true }; + case "warm": + return { label: "ליד חם", warn: false }; + case "active": + return { label: "פעיל", warn: false }; + case "dormant": + return { label: "רדום", warn: false }; + default: + return { label: String(status ?? ""), warn: false }; + } +} + +// ── Meetings: day grouping + month grid ────────────────── + +/** UTC midnight epoch (ms) for a `YYYY-MM-DD` key, or NaN if unparseable. */ +function utcOf(dayKey) { + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(dayKey ?? "")); + if (!m) return Number.NaN; + return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])); +} + +/** + * Relative-day classification of a `YYYY-MM-DD` key against `nowIso`. + * @param {string} dayKey + * @param {string} nowIso - reference "now" as an ISO string + * @returns {"today"|"tomorrow"|"yesterday"|"other"} + */ +export function relativeDay(dayKey, nowIso) { + const a = utcOf(dayKey); + const b = utcOf(String(nowIso ?? "").slice(0, 10)); + if (Number.isNaN(a) || Number.isNaN(b)) return "other"; + const diff = Math.round((a - b) / 86_400_000); + if (diff === 0) return "today"; + if (diff === 1) return "tomorrow"; + if (diff === -1) return "yesterday"; + return "other"; +} + +/** + * Group meetings into day buckets, chronologically. Each bucket carries the + * `YYYY-MM-DD` key, its relative-day classification, and the meetings sorted by + * start time. Meetings with a null `startsAt` collect into a trailing + * `relative: "none"` bucket (key `null`) so they're never silently dropped. + * @param {Array<{startsAt: string|null}>} meetings + * @param {string} nowIso + * @returns {Array<{ key: string|null, relative: string, items: Array }>} + */ +export function groupMeetingsByDay(meetings, nowIso) { + const list = Array.isArray(meetings) ? meetings : []; + /** @type {Map} */ + const byDay = new Map(); + const unscheduled = []; + for (const m of list) { + if (!m || typeof m !== "object") continue; + const starts = typeof m.startsAt === "string" ? m.startsAt : null; + if (!starts) { + unscheduled.push(m); + continue; + } + const key = starts.slice(0, 10); + if (!byDay.has(key)) byDay.set(key, []); + byDay.get(key).push(m); + } + const groups = [...byDay.keys()] + .sort() + .map((key) => ({ + key, + relative: relativeDay(key, nowIso), + items: byDay.get(key).slice().sort((a, b) => String(a.startsAt).localeCompare(String(b.startsAt))), + })); + if (unscheduled.length) { + groups.push({ key: null, relative: "none", items: unscheduled }); + } + return groups; +} + +/** + * Set of day-of-month numbers (1–31) that carry at least one meeting in the + * given UTC year/month. Drives the calendar's event dots. + * @param {Array<{startsAt: string|null}>} meetings + * @param {number} year + * @param {number} monthIndex - 0-based (Jan = 0) + * @returns {Set} + */ +export function eventDaySet(meetings, year, monthIndex) { + const days = new Set(); + for (const m of Array.isArray(meetings) ? meetings : []) { + const starts = m && typeof m.startsAt === "string" ? m.startsAt : null; + if (!starts) continue; + const mm = /^(\d{4})-(\d{2})-(\d{2})/.exec(starts); + if (!mm) continue; + if (Number(mm[1]) === year && Number(mm[2]) - 1 === monthIndex) { + days.add(Number(mm[3])); + } + } + return days; +} + +/** + * Build a month grid: leading `null` blanks for the days before the 1st + * (week starts Sunday), then one cell per day flagged for today + events. + * @param {number} year + * @param {number} monthIndex - 0-based + * @param {{ today?: number|null, events?: Set }} [opts] + * @returns {Array} + */ +export function buildMonthGrid(year, monthIndex, { today = null, events = new Set() } = {}) { + const firstDow = new Date(Date.UTC(year, monthIndex, 1)).getUTCDay(); + const daysIn = new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate(); + const cells = []; + for (let i = 0; i < firstDow; i++) cells.push(null); + for (let d = 1; d <= daysIn; d++) { + cells.push({ day: d, isToday: d === today, hasEvent: events.has(d) }); + } + return cells; +} + +// ── To-dos: progress ───────────────────────────────────── + +/** + * Checklist progress over a todo list. + * @param {Array<{done?: boolean}>} todos + * @returns {{ done: number, total: number, open: number, pct: number }} + */ +export function todoProgress(todos) { + const list = Array.isArray(todos) ? todos : []; + const total = list.length; + const done = list.filter((t) => t && t.done).length; + const open = total - done; + const pct = total ? Math.round((done / total) * 100) : 0; + return { done, total, open, pct }; +} diff --git a/src/web/public/lib/agenda.test.js b/src/web/public/lib/agenda.test.js new file mode 100644 index 0000000..34c09e4 --- /dev/null +++ b/src/web/public/lib/agenda.test.js @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; +import { + avatarTint, + buildMonthGrid, + eventDaySet, + groupMeetingsByDay, + hueFromName, + initials, + peopleStatusMeta, + relativeDay, + todoProgress, +} from "./agenda.js"; + +describe("initials", () => { + it("takes the first letter of up to two words", () => { + expect(initials("דנה כהן")).toBe("דכ"); + expect(initials("משה לוי שני")).toBe("מל"); + }); + + it("skips leading punctuation inside a word", () => { + expect(initials("אבי (קבלן)")).toBe("אק"); + }); + + it("handles a single word and empty/blank input", () => { + expect(initials("רונית")).toBe("ר"); + expect(initials("")).toBe(""); + expect(initials(" ")).toBe(""); + expect(initials(null)).toBe(""); + }); +}); + +describe("hueFromName", () => { + it("is deterministic and in range", () => { + const h = hueFromName("יוסי טל"); + expect(h).toBe(hueFromName("יוסי טל")); + expect(h).toBeGreaterThanOrEqual(0); + expect(h).toBeLessThan(360); + }); + + it("differs for different names (generally)", () => { + expect(hueFromName("משה לוי")).not.toBe(hueFromName("דנה כהן")); + }); +}); + +describe("avatarTint", () => { + it("builds oklch bg/fg from the name hue", () => { + const t = avatarTint("דנה כהן"); + expect(t.initials).toBe("דכ"); + expect(t.bg).toBe(`oklch(0.93 0.045 ${t.hue})`); + expect(t.fg).toBe(`oklch(0.42 0.09 ${t.hue})`); + }); +}); + +describe("peopleStatusMeta", () => { + it("maps cold-lead to a warn badge", () => { + expect(peopleStatusMeta("cold-lead")).toEqual({ label: "ליד מתקרר", warn: true }); + }); + + it("maps the non-warn statuses", () => { + expect(peopleStatusMeta("active").warn).toBe(false); + expect(peopleStatusMeta("warm").label).toBe("ליד חם"); + expect(peopleStatusMeta("dormant").label).toBe("רדום"); + }); + + it("falls back to the raw value for an unknown status", () => { + expect(peopleStatusMeta("mystery")).toEqual({ label: "mystery", warn: false }); + expect(peopleStatusMeta(null)).toEqual({ label: "", warn: false }); + }); +}); + +describe("relativeDay", () => { + const now = "2026-06-12T09:00:00.000Z"; + it("classifies today/tomorrow/yesterday", () => { + expect(relativeDay("2026-06-12", now)).toBe("today"); + expect(relativeDay("2026-06-13", now)).toBe("tomorrow"); + expect(relativeDay("2026-06-11", now)).toBe("yesterday"); + }); + it("returns other for distant days and unparseable input", () => { + expect(relativeDay("2026-06-20", now)).toBe("other"); + expect(relativeDay("nope", now)).toBe("other"); + }); +}); + +describe("groupMeetingsByDay", () => { + const now = "2026-06-12T09:00:00.000Z"; + const meetings = [ + { id: 1, startsAt: "2026-06-13T09:00:00Z", title: "ב" }, + { id: 2, startsAt: "2026-06-12T17:30:00Z", title: "א-late" }, + { id: 3, startsAt: "2026-06-12T14:00:00Z", title: "א-early" }, + { id: 4, startsAt: null, title: "ללא זמן" }, + ]; + + it("buckets by UTC day, sorted chronologically", () => { + const groups = groupMeetingsByDay(meetings, now); + expect(groups.map((g) => g.key)).toEqual(["2026-06-12", "2026-06-13", null]); + expect(groups[0].relative).toBe("today"); + expect(groups[1].relative).toBe("tomorrow"); + expect(groups[2].relative).toBe("none"); + }); + + it("sorts meetings within a day by start time", () => { + const groups = groupMeetingsByDay(meetings, now); + expect(groups[0].items.map((m) => m.id)).toEqual([3, 2]); + }); + + it("collects null-start meetings into a trailing bucket", () => { + const groups = groupMeetingsByDay(meetings, now); + expect(groups.at(-1).items.map((m) => m.id)).toEqual([4]); + }); + + it("handles empty / non-array input", () => { + expect(groupMeetingsByDay([], now)).toEqual([]); + expect(groupMeetingsByDay(null, now)).toEqual([]); + }); +}); + +describe("eventDaySet", () => { + const meetings = [ + { startsAt: "2026-06-10T09:00:00Z" }, + { startsAt: "2026-06-10T18:00:00Z" }, + { startsAt: "2026-06-12T14:00:00Z" }, + { startsAt: "2026-07-01T10:00:00Z" }, // different month + { startsAt: null }, + ]; + it("collects day-of-month numbers for the given month only", () => { + const set = eventDaySet(meetings, 2026, 5); // June (0-based) + expect([...set].sort((a, b) => a - b)).toEqual([10, 12]); + }); +}); + +describe("buildMonthGrid", () => { + it("pads leading blanks for the first weekday and flags today + events", () => { + // June 2026: the 1st is a Monday (getUTCDay === 1). + const cells = buildMonthGrid(2026, 5, { today: 12, events: new Set([10, 12]) }); + expect(cells.slice(0, 1)).toEqual([null]); // one blank before Monday + const first = cells[1]; + expect(first).toEqual({ day: 1, isToday: false, hasEvent: false }); + const twelfth = cells.find((c) => c && c.day === 12); + expect(twelfth).toEqual({ day: 12, isToday: true, hasEvent: true }); + const tenth = cells.find((c) => c && c.day === 10); + expect(tenth.hasEvent).toBe(true); + // June has 30 days → 1 blank + 30 day cells. + expect(cells.filter(Boolean)).toHaveLength(30); + }); +}); + +describe("todoProgress", () => { + it("computes done/open/pct", () => { + const todos = [{ done: true }, { done: false }, { done: false }, { done: true }]; + expect(todoProgress(todos)).toEqual({ done: 2, total: 4, open: 2, pct: 50 }); + }); + it("is safe on an empty list", () => { + expect(todoProgress([])).toEqual({ done: 0, total: 0, open: 0, pct: 0 }); + expect(todoProgress(null)).toEqual({ done: 0, total: 0, open: 0, pct: 0 }); + }); +}); From c5a07c965e43f1b011d0bfeae4ddc41da51e0a94 Mon Sep 17 00:00:00 2001 From: Eyal Delarea <23456142+EyalDelarea@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:45:11 +0300 Subject: [PATCH 2/5] feat(ui): add users + target icon glyphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `users` heads the People (§5) view + its empty state; `target` marks the "הצעד הבא" card. currentColor-driven like the rest. Co-Authored-By: Claude Opus 4.8 --- src/web/public/lib/icons.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/web/public/lib/icons.js b/src/web/public/lib/icons.js index 2d0e53c..d3e6cc5 100644 --- a/src/web/public/lib/icons.js +++ b/src/web/public/lib/icons.js @@ -45,6 +45,11 @@ const PATHS = { user: '', flame: '', arrowL: '', + // ── People (§5) + Meetings/To-dos (§6) glyphs ───────── + users: + '' + + '', + target: '', }; /** From bda60a682d674bcdab3cb21955d588387db51db5 Mon Sep 17 00:00:00 2001 From: Eyal Delarea <23456142+EyalDelarea@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:45:11 +0300 Subject: [PATCH 3/5] feat(ui): add People + Meetings/To-dos API client methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getPeople / getMeetings(from?,to?) / getTodos / setTodoDone(id,done), mirroring the getScopes/putScopes/getToday style (throw on !ok, JSDoc'd shapes). setTodoDone PATCHes same-origin JSON so the CSRF guard passes. The endpoints don't exist yet — callers treat a throw as empty. Co-Authored-By: Claude Opus 4.8 --- src/web/public/lib/api.js | 97 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/src/web/public/lib/api.js b/src/web/public/lib/api.js index 2d713fc..7138082 100644 --- a/src/web/public/lib/api.js +++ b/src/web/public/lib/api.js @@ -318,3 +318,100 @@ export function actOnSuggestion(id, action, finalText) { return r.json(); }); } + +/** + * @typedef {{ + * id: number, + * name: string, + * status: "active"|"cold-lead"|"warm"|"dormant", + * lastContactAt: string|null, + * openThreads: number, + * nextStep: string|null, + * sourceMessageId: number|null, + * chat: string|null + * }} Person + */ + +/** + * Fetch the People/CRM list (§5). Derived server-side from messages. Throws on a + * non-OK response; the People view treats that (e.g. a 404 while the endpoint is + * still being built) as an empty list and renders the empty state. + * @returns {Promise} + */ +export function getPeople() { + return fetch("/api/people").then((r) => { + if (!r.ok) throw new Error(`people ${r.status}`); + return r.json(); + }); +} + +/** + * @typedef {{ + * id: number, + * title: string, + * startsAt: string|null, + * owner: string|null, + * chat: string, + * sourceMessageId: number + * }} Meeting + */ + +/** + * Fetch extracted meetings (§6), optionally bounded by an ISO `from`/`to` + * window. Throws on a non-OK response; the Agenda view renders an empty state. + * @param {string} [from] - inclusive ISO lower bound + * @param {string} [to] - exclusive ISO upper bound + * @returns {Promise} + */ +export function getMeetings(from, to) { + const qs = new URLSearchParams(); + if (from) qs.set("from", from); + if (to) qs.set("to", to); + const suffix = qs.toString() ? `?${qs}` : ""; + return fetch(`/api/meetings${suffix}`).then((r) => { + if (!r.ok) throw new Error(`meetings ${r.status}`); + return r.json(); + }); +} + +/** + * @typedef {{ + * id: number, + * title: string, + * dueAt: string|null, + * owner: string|null, + * done: boolean, + * chat: string, + * sourceMessageId: number + * }} Todo + */ + +/** + * Fetch extracted to-dos (§6). Throws on a non-OK response; the Agenda view + * renders an empty state. + * @returns {Promise} + */ +export function getTodos() { + return fetch("/api/todos").then((r) => { + if (!r.ok) throw new Error(`todos ${r.status}`); + return r.json(); + }); +} + +/** + * Toggle a to-do's done state. Same-origin (cookies + JSON content-type) so the + * CSRF guard passes. Returns the updated to-do. + * @param {number} id + * @param {boolean} done + * @returns {Promise} + */ +export function setTodoDone(id, done) { + return fetch(`/api/todos/${id}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ done }), + }).then((r) => { + if (!r.ok) throw new Error(`setTodoDone ${r.status}`); + return r.json(); + }); +} From 17f4fd95c35f24f8d1158ff9923a48915a93e98e Mon Sep 17 00:00:00 2001 From: Eyal Delarea <23456142+EyalDelarea@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:45:24 +0300 Subject: [PATCH 4/5] =?UTF-8?q?feat(ui):=20build=20People=20(=C2=A75)=20an?= =?UTF-8?q?d=20Meetings/To-dos=20(=C2=A76)=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new views wired into the navigate/popstate/hash state machine, with אנשים and פגישות ומשימות top-bar entries: - People: two-pane (list + sticky detail) that stacks on mobile. Rows show a tinted initials disc, status badge (cold-lead → warn), next-step note and "קשר אחרון · N שיחות פתוחות". Detail has a "הצעד הבא" card with a source chip → S2 thread jump, plus "פתח צ׳אט" / "+ משימה" (the latter static for this slice). - Meetings/To-dos: a month calendar (today + event dots, prev/next month) and a day-grouped agenda timeline on one side; a checklist with a progress bar and optimistic round-checkbox toggles on the other. Local-only — no Google-Calendar connect banner. Source chips reuse the S2 thread jump. Every fetch failure (the endpoints don't exist yet) renders a graceful empty state instead of crashing. Co-Authored-By: Claude Opus 4.8 --- src/web/public/app.js | 485 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 483 insertions(+), 2 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 826302f..1b050a2 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -14,7 +14,8 @@ * Teardown: EventSource is closed when leaving a streaming view */ -import { actOnSuggestion, askStream, createScopeCategory, getGroups, getMessages, getPreferences, getScopeCategories, getScopes, getStatus, getSummaries, getToday, putPreferences, putScopes, summarizeStream } from "./lib/api.js"; +import { actOnSuggestion, askStream, createScopeCategory, getGroups, getMeetings, getMessages, getPeople, getPreferences, getScopeCategories, getScopes, getStatus, getSummaries, getToday, getTodos, putPreferences, putScopes, setTodoDone, summarizeStream } from "./lib/api.js"; +import { avatarTint, buildMonthGrid, eventDaySet, groupMeetingsByDay, peopleStatusMeta, relativeDay, todoProgress } from "./lib/agenda.js"; import { activeCount, filterScopes, groupByCategory, partitionRemoved, sectionCount } from "./lib/scopes.js"; import { DIGEST_CHOICES, ENGINE_KINDS, PROACT_LEVELS, isDigestSelected, normalizeEngineConfig, toggleDigestTime } from "./lib/prefs.js"; import { buildDeck, clampIndex, commitActionFor, emptyTally, greeting, indexAfterRemoval, isSuggestion, leavingVariant, peekCount, recordTally, removeCardById, segmentFills, suggestionConfig, tallyBits, tileCounts, TILE_KINDS } from "./lib/today.js"; @@ -78,7 +79,7 @@ function setView(view) { /** * Navigate to a view, pushing a history entry. - * @param {"feed"|"detail"|"total"|"ama"|"thread"|"sources"|"settings"|"today"} view + * @param {"feed"|"detail"|"total"|"ama"|"thread"|"sources"|"settings"|"today"|"people"|"agenda"} view * @param {string} [arg] — group name (detail) or AMA scope (ama) */ function navigate(view, arg) { @@ -108,6 +109,12 @@ function navigate(view, arg) { } else if (view === "settings") { history.pushState({ view: "settings" }, "", "#settings"); renderSettings(); + } else if (view === "people") { + history.pushState({ view: "people" }, "", "#people"); + renderPeople(); + } else if (view === "agenda") { + history.pushState({ view: "agenda" }, "", "#agenda"); + renderAgenda(); } else { history.pushState({ view: "feed" }, "", location.pathname); setView("feed"); @@ -128,6 +135,10 @@ window.addEventListener("popstate", (e) => { renderSources(); } else if (state?.view === "settings") { renderSettings(); + } else if (state?.view === "people") { + renderPeople(); + } else if (state?.view === "agenda") { + renderAgenda(); } else if (state?.view === "ama") { renderAma(state.scope ?? null); } else if (state?.view === "thread" && state.chat) { @@ -200,6 +211,18 @@ function renderShell() { מה קרה בכל הצ׳אטים הקש לסיכום › + +
טוען… @@ -226,6 +249,8 @@ function renderShell() { document.getElementById("today-card")?.addEventListener("click", () => navigate("today")); document.getElementById("ama-card").addEventListener("click", () => navigate("ama")); document.getElementById("total-card").addEventListener("click", () => navigate("total")); + document.getElementById("people-card")?.addEventListener("click", () => navigate("people")); + document.getElementById("agenda-card")?.addEventListener("click", () => navigate("agenda")); document.getElementById("theme-toggle")?.addEventListener("click", toggleTheme); document.getElementById("settings-gear")?.addEventListener("click", () => navigate("settings")); document.getElementById("manage-sources")?.addEventListener("click", () => navigate("sources")); @@ -2168,6 +2193,450 @@ function showTodayFlash(text) { setTimeout(() => el.classList.remove("show"), 1100); } +/* ── 7e. People (§5) ─────────────────────────────────────── */ +// +// Two-pane (list + sticky detail) on desktop, stacks on mobile. Fetch-on-entry, +// then a thin paint. The People/CRM endpoint may not exist yet — any failure +// (404 / network) renders the empty state rather than crashing. + +const peopleState = { people: [], selected: 0 }; + +/** A per-name tinted initials disc (oklch tint hashed from the name). */ +function buildAvatarDisc(name, size = 42) { + const t = avatarTint(name); + return ``; +} + +/** Source chip → S2 thread jump. Falls back to a plain label when the entity + * carries no jumpable `{chat, sourceMessageId}`. Shared by People + Agenda. */ +function buildSrcJump({ chat, sourceMessageId, label }) { + const text = escHtml(label ?? (chat ? formatGroupName(chat) : "מקור")); + const id = Number(sourceMessageId); + if (!chat || !Number.isFinite(id)) { + return `${icon("arrowL", { size: 13 })}${text}`; + } + return ``; +} + +/** Delegate source-chip clicks within a container to the S2 thread jump. */ +function wireSrcJumps(root) { + for (const chip of root.querySelectorAll("[data-src-jump]")) { + chip.addEventListener("click", (e) => { + e.stopPropagation(); + const id = Number(chip.dataset.id); + if (chip.dataset.chat && Number.isFinite(id)) { + navigate("thread", { chat: chip.dataset.chat, aroundId: id }); + } + }); + } +} + +/** A back-nav row (mobile shows it; the top-bar is hidden off-feed). */ +function buildEntityNav(backId) { + return ` + `; +} + +async function renderPeople() { + teardownStream(); + setView("ama"); // reuse the single-pane slot, like Sources / Settings / Today + markActiveRow(null); + paneMain.innerHTML = `

טוען אנשים…

`; + let people = []; + try { + people = DEMO ? [] : await getPeople(); + } catch { + people = []; // endpoint not built yet / network → empty state + } + peopleState.people = Array.isArray(people) ? people : []; + peopleState.selected = 0; + paintPeople(); +} + +function paintPeople() { + const { people } = peopleState; + if (people.length === 0) { + paneMain.innerHTML = ` +
+ ${buildEntityNav("people-back")} +
+
${icon("users", { size: 18 })} אנשים
+
+ ${buildEntityEmpty("users", "אין עדיין אנשים", "כשתתחילו לשוחח, CatchApp יזהה לידים ואנשי קשר שמחכים לתשובה — והם יופיעו כאן.")} +
`; + document.getElementById("people-back")?.addEventListener("click", () => history.back()); + return; + } + + const sel = Math.max(0, Math.min(peopleState.selected, people.length - 1)); + peopleState.selected = sel; + const warmWaiting = people.filter((p) => p.openThreads > 0).length; + + paneMain.innerHTML = ` +
+ ${buildEntityNav("people-back")} +
+
${icon("users", { size: 18 })} אנשים
+ ${warmWaiting}/${people.length} +
+
+
+ ${people.map((p, i) => buildPersonRow(p, i === sel)).join("")} +
+ ${buildPersonDetail(people[sel])} +
+
`; + wirePeople(); +} + +function buildPersonRow(p, isSel) { + const meta = peopleStatusMeta(p.status); + const last = formatAgo(p.lastContactAt) ?? "אין קשר עדיין"; + const note = p.nextStep ? escHtml(p.nextStep) : "אין צעד פתוח"; + const open = p.openThreads > 0 + ? ` · ${p.openThreads} שיחות פתוחות` + : ""; + return ` + `; +} + +function buildPersonDetail(p) { + const meta = peopleStatusMeta(p.status); + const last = formatAgo(p.lastContactAt) ?? "אין קשר עדיין"; + const chip = buildSrcJump({ chat: p.chat, sourceMessageId: p.sourceMessageId, label: "מההודעה" }); + const openChat = p.chat + ? `` + : ``; + return ` + `; +} + +function wirePeople() { + document.getElementById("people-back")?.addEventListener("click", () => history.back()); + const root = paneMain.querySelector(".people"); + if (!root) return; + for (const row of root.querySelectorAll(".ppl-row")) { + row.addEventListener("click", () => { + const id = Number(row.dataset.idx); + const idx = peopleState.people.findIndex((p) => p.id === id); + if (idx >= 0) { + peopleState.selected = idx; + paintPeople(); + } + }); + } + const sel = peopleState.people[peopleState.selected]; + document.getElementById("ppl-open-chat")?.addEventListener("click", () => { + if (sel?.chat) navigate("detail", sel.chat); + }); + // "+ משימה" is intentionally static for this UI-only slice. + wireSrcJumps(root); +} + +/* ── 7f. Meetings & To-dos (§6) ──────────────────────────── */ +// +// Two visually-distinct columns (`.duo`): a month calendar + day-grouped agenda +// timeline on the left, and a checklist with a progress bar on the right. +// Local-only — no Google-Calendar connect banner (that's the S8 gated work). +// Both endpoints may be absent; any failure renders empty columns, never crashes. + +const agendaState = { meetings: [], todos: [], monthOffset: 0 }; +const DOW_HE = ["א", "ב", "ג", "ד", "ה", "ו", "ש"]; + +async function renderAgenda() { + teardownStream(); + setView("ama"); + markActiveRow(null); + paneMain.innerHTML = `

טוען פגישות ומשימות…

`; + let meetings = []; + let todos = []; + if (!DEMO) { + [meetings, todos] = await Promise.all([ + getMeetings().catch(() => []), + getTodos().catch(() => []), + ]); + } + agendaState.meetings = Array.isArray(meetings) ? meetings : []; + agendaState.todos = Array.isArray(todos) ? todos : []; + agendaState.monthOffset = 0; + paintAgenda(); +} + +function paintAgenda() { + paneMain.innerHTML = ` +
+ ${buildEntityNav("agenda-back")} +
+
${icon("calendar", { size: 18 })} פגישות ומשימות
+
+
+
+

${icon("calendar", { size: 15 })} פגישות שנאספו

+ ${buildCalendar()} + ${buildAgendaTimeline()} +
+
+ ${buildTodosColumn()} +
+
+
`; + wireAgenda(); +} + +/** Month calendar (today highlighted, event dots). Prev/next shift the month. */ +function buildCalendar() { + const base = new Date(); + base.setDate(1); + base.setMonth(base.getMonth() + agendaState.monthOffset); + const year = base.getFullYear(); + const monthIndex = base.getMonth(); + const isCurrent = agendaState.monthOffset === 0; + const todayDay = isCurrent ? new Date().getDate() : null; + const events = eventDaySet(agendaState.meetings, year, monthIndex); + const cells = buildMonthGrid(year, monthIndex, { today: todayDay, events }); + let monthLabel = `${year}`; + try { + monthLabel = base.toLocaleDateString("he-IL", { month: "long", year: "numeric" }); + } catch { + /* leave numeric fallback */ + } + const dow = DOW_HE.map((d) => `${d}`).join(""); + const grid = cells + .map((c) => { + if (c == null) return `
`; + const dots = c.hasEvent ? `` : ""; + return `
${c.day}${dots}
`; + }) + .join(""); + return ` +
+
+ ${escHtml(monthLabel)} +
+ + +
+
+ +
${grid}
+
`; +} + +/** Hebrew day-group heading from the pure group's relative classification. */ +function agendaDayLabel(group) { + switch (group.relative) { + case "today": + return "היום"; + case "tomorrow": + return "מחר"; + case "yesterday": + return "אתמול"; + case "none": + return "ללא תאריך"; + default: + try { + return new Date(`${group.key}T00:00:00Z`).toLocaleDateString("he-IL", { + weekday: "long", + day: "numeric", + month: "long", + }); + } catch { + return group.key ?? ""; + } + } +} + +/** Day-grouped agenda timeline (rail + dots). Grouping is the pure lib's. */ +function buildAgendaTimeline() { + const groups = groupMeetingsByDay(agendaState.meetings, new Date().toISOString()); + if (groups.length === 0) { + return `
${buildEntityEmpty("calendar", "אין פגישות", "פגישות שיזוהו בשיחות יופיעו כאן, מקובצות לפי יום.")}
`; + } + return ` +
+ ${groups + .map( + (g) => ` +
+
+ ${escHtml(agendaDayLabel(g))} + ${g.items.length} פגישות +
+
+ ${g.items.map(buildMeetingItem).join("")} +
+
`, + ) + .join("")} +
`; +} + +function buildMeetingItem(m) { + let time = ""; + if (m.startsAt) { + try { + time = new Date(m.startsAt).toLocaleTimeString("he-IL", { hour: "2-digit", minute: "2-digit" }); + } catch { + time = ""; + } + } + const owner = m.owner ? `${icon("user", { size: 13 })}${escHtml(formatGroupName(m.owner))}` : ""; + const chip = buildSrcJump({ chat: m.chat, sourceMessageId: m.sourceMessageId }); + return ` +
+
${escHtml(time) || "—"}
+ +
+

${escHtml(m.title)}

+
${owner}${chip}
+
+
`; +} + +/** To-dos checklist column: progress bar + round-checkbox rows. */ +function buildTodosColumn() { + const todos = agendaState.todos; + const inner = + todos.length === 0 + ? buildEntityEmpty("checks", "אין משימות פתוחות", "משימות שיחולצו מהשיחות יופיעו כאן, עם מקור ותאריך יעד.") + : buildChecklist(todos); + return `

${icon("checks", { size: 15 })} משימות שחולצו

${inner}`; +} + +function buildChecklist(todos) { + const p = todoProgress(todos); + const rows = todos.map(buildTodoRow).join(""); + return ` +
+
+ ${p.done} מתוך ${p.total} הושלמו + ${p.open} פתוחות +
+
+ +
+
${rows}
+
`; +} + +function buildTodoRow(t) { + const chip = buildSrcJump({ chat: t.chat, sourceMessageId: t.sourceMessageId }); + const due = dueLabel(t.dueAt); + const dueBadge = due ? `${escHtml(due)}` : ""; + return ` +
+ +
+
${escHtml(t.title)}
+
${chip}${dueBadge}
+
+
`; +} + +/** Short Hebrew due-date label from an ISO `dueAt` (null → no badge). */ +function dueLabel(dueAt) { + if (!dueAt) return ""; + const rel = relativeDay(String(dueAt).slice(0, 10), new Date().toISOString()); + if (rel === "today") return "להיום"; + if (rel === "tomorrow") return "עד מחר"; + if (rel === "yesterday") return "היה אתמול"; + try { + return `עד ${new Date(`${String(dueAt).slice(0, 10)}T00:00:00Z`).toLocaleDateString("he-IL", { weekday: "long" })}`; + } catch { + return ""; + } +} + +function paintTodos() { + const col = document.getElementById("agenda-todos"); + if (!col) return; + col.innerHTML = buildTodosColumn(); + for (const btn of col.querySelectorAll("[data-todo-toggle]")) { + btn.addEventListener("click", () => onTodoToggle(Number(btn.dataset.todoToggle))); + } + wireSrcJumps(col); +} + +/** Optimistic checkbox toggle; reverts + repaints on a failed PATCH. */ +async function onTodoToggle(id) { + const t = agendaState.todos.find((x) => x.id === id); + if (!t) return; + const next = !t.done; + t.done = next; + paintTodos(); + if (DEMO) return; + try { + await setTodoDone(id, next); + } catch { + t.done = !next; // revert + paintTodos(); + } +} + +function wireAgenda() { + document.getElementById("agenda-back")?.addEventListener("click", () => history.back()); + const root = paneMain.querySelector(".agenda-view"); + if (!root) return; + for (const btn of root.querySelectorAll("[data-cal-nav]")) { + btn.addEventListener("click", () => { + agendaState.monthOffset += btn.dataset.calNav === "next" ? 1 : -1; + paintAgenda(); + }); + } + for (const btn of root.querySelectorAll("[data-todo-toggle]")) { + btn.addEventListener("click", () => onTodoToggle(Number(btn.dataset.todoToggle))); + } + wireSrcJumps(root); +} + +/** Shared empty-state card for People + Agenda columns. */ +function buildEntityEmpty(iconName, title, text) { + return ` +
+
${icon(iconName, { size: 24 })}
+

${escHtml(title)}

+

${escHtml(text)}

+
`; +} + /* ── 8. Helpers ──────────────────────────────────────────── */ function formatGroupName(name) { @@ -2234,6 +2703,14 @@ function resolveInitialRoute() { history.replaceState({ view: "settings" }, "", hash); return { view: "settings" }; } + if (hash === "#people") { + history.replaceState({ view: "people" }, "", hash); + return { view: "people" }; + } + if (hash === "#agenda") { + history.replaceState({ view: "agenda" }, "", hash); + return { view: "agenda" }; + } history.replaceState({ view: "feed" }, "", location.pathname); return { view: "feed" }; } @@ -2609,6 +3086,10 @@ async function boot() { renderSources(); } else if (route.view === "settings") { renderSettings(); + } else if (route.view === "people") { + renderPeople(); + } else if (route.view === "agenda") { + renderAgenda(); } else { setView("feed"); renderMainWelcome(); From 7e5d05efd7b0befcc4733474a120790942409601 Mon Sep 17 00:00:00 2001 From: Eyal Delarea <23456142+EyalDelarea@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:45:24 +0300 Subject: [PATCH 5/5] style(ui): style People + Meetings/To-dos screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tokenized, RTL-via-logical-properties styles for the new views: shared entity chrome (nav/head/badge/avatar/source chip/empty), the People two-pane split, and the agenda calendar + timeline + checklist. Two-pane/two-column kick in at ≥900px; transitions are gated behind prefers-reduced-motion. Co-Authored-By: Claude Opus 4.8 --- src/web/public/styles.css | 308 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) diff --git a/src/web/public/styles.css b/src/web/public/styles.css index c0dea3c..051e20b 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -3227,3 +3227,311 @@ body::after { @media (max-width: 899px) { .feat--today { flex: 0 0 auto; } } + +/* ═══════════════════════════════════════════════════════════ + §5 People + §6 Meetings & To-dos (S7) + Two new top-level views. RTL via logical properties throughout; + numbers/dates wrapped dir="ltr" in the markup. Motion is gated. + ═══════════════════════════════════════════════════════════ */ + +/* ── Top-bar entries ─────────────────────────────────────── */ +.feat--people { + background: linear-gradient(150deg, rgba(180, 132, 63, 0.14), rgba(180, 132, 63, 0.04)); + border-color: rgba(180, 132, 63, 0.38); +} +.feat--agenda { + background: linear-gradient(150deg, rgba(82, 128, 106, 0.15), rgba(82, 128, 106, 0.04)); + border-color: rgba(82, 128, 106, 0.42); +} +@media (max-width: 899px) { + .feat--people, .feat--agenda { flex: 0 0 auto; } +} + +/* ── Shared entity chrome (nav · head · badges · avatar · chip) ── */ +.people, .agenda-view { padding: 12px; } +.entity-nav { inline-size: 100%; } +.entity-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-block: 4px 12px; +} +.entity-head__title { + display: flex; + align-items: center; + gap: 8px; + font-weight: 700; + font-size: var(--text-lg); + color: var(--ink); +} +.entity-head__title .ic { color: var(--accent-ink); } +.entity-count { + font-size: var(--text-sm); + color: var(--accent-ink); + background: var(--accent-weak); + border-radius: var(--radius-pill); + padding: 3px 10px; +} + +.entity-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: var(--text-xs); + font-weight: 600; + padding: 2px 9px; + border-radius: var(--radius-pill); + background: var(--surface-2); + color: var(--ink-2); + border: 1px solid var(--line); +} +.entity-badge.is-accent { background: var(--accent-weak); color: var(--accent-ink); border-color: transparent; } +.entity-badge.is-warn { background: var(--warn-weak); color: var(--warn-ink); border-color: var(--warn); } + +.entity-avatar { + display: inline-grid; + place-items: center; + flex: none; + inline-size: var(--av-sz, 42px); + block-size: var(--av-sz, 42px); + border-radius: 50%; + background: var(--av-bg); + color: var(--av-fg); + font-weight: 700; + font-size: var(--av-fs, 15px); + line-height: 1; +} + +.srcchip { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: var(--text-sm); + font-weight: 500; + color: var(--accent-ink); + background: var(--accent-weak); + padding: 4px 9px; + border: 0; + border-radius: 8px; + cursor: pointer; + font-family: inherit; +} +button.srcchip:hover { filter: brightness(0.97); } +.srcchip .ic { width: 13px; height: 13px; } + +.entity-empty { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 8px; + padding: 30px 20px; +} +.entity-empty__ico { + display: grid; + place-items: center; + inline-size: 52px; + block-size: 52px; + border-radius: 50%; + background: var(--accent-weak); + color: var(--accent-ink); +} +.entity-empty h3 { margin: 0; font-size: var(--text-md); color: var(--ink); } +.entity-empty p { margin: 0; font-size: var(--text-base); color: var(--ink-2); max-inline-size: 34ch; line-height: 1.5; } + +/* ── People (§5) ─────────────────────────────────────────── */ +.split { display: flex; flex-direction: column; gap: 14px; } +.ppl-list { display: flex; flex-direction: column; gap: 10px; } +.ppl-row { + display: flex; + align-items: flex-start; + gap: 12px; + text-align: start; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: var(--radius-lg); + background: var(--surface); + cursor: pointer; + font-family: inherit; + color: var(--ink); + transition: border-color 0.15s, background 0.15s; +} +.ppl-row:hover { background: var(--hover); } +.ppl-row.is-sel { border-color: var(--accent); background: var(--accent-weak); } +.ppl-row__body { flex: 1 1 auto; min-inline-size: 0; } +.ppl-row__name { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + font-weight: 700; + font-size: var(--text-base); + color: var(--ink); +} +.ppl-row__note { margin: 3px 0 0; font-size: var(--text-sm); color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; } +.ppl-row__meta { margin-top: 5px; font-size: var(--text-xs); color: var(--muted); } + +.ppl-detail { padding: 20px; align-self: flex-start; } +.ppl-detail__head { display: flex; align-items: center; gap: 13px; margin-bottom: 16px; } +.ppl-detail__name { font-size: var(--text-lg); font-weight: 800; letter-spacing: -0.01em; color: var(--ink); } +.ppl-detail__id .entity-badge { margin-top: 5px; } +.ppl-detail__divide { block-size: 1px; background: var(--line); margin-block: 14px; } +.ppl-detail__rows { display: flex; flex-direction: column; gap: 12px; } +.ppl-detail__row { display: flex; align-items: center; gap: 9px; font-size: var(--text-base); } +.ppl-detail__row .ic { color: var(--muted); flex: none; } +.ppl-detail__row span { flex: 1 1 auto; color: var(--ink-2); } +.ppl-detail__row b { color: var(--ink); } +.ppl-next__kicker { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 8px; + font-size: var(--text-xs); + font-weight: 700; + letter-spacing: 0.5px; + color: var(--accent-ink); + text-transform: uppercase; +} +.ppl-next { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 11px 13px; + background: var(--surface-2); + border: 0; + border-radius: 12px; + margin-bottom: 14px; + font-size: var(--text-base); + color: var(--ink); +} +.ppl-next > span { flex: 1 1 auto; min-inline-size: 0; } +.ppl-detail__actions { display: flex; gap: 8px; } +.ppl-detail__actions .btn-primary { flex: 1; } + +/* ── Meetings & To-dos (§6) ──────────────────────────────── */ +.duo { display: flex; flex-direction: column; gap: 22px; } +.duo__col { display: flex; flex-direction: column; gap: 12px; min-inline-size: 0; } +.duo__sec { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: var(--text-md); + font-weight: 700; + color: var(--ink); +} +.duo__sec .ic { color: var(--accent-ink); } + +/* month calendar */ +.cal { padding: 14px; } +.cal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } +.cal-head b { font-size: var(--text-base); color: var(--ink); } +.cal-nav { display: flex; gap: 4px; } +.cal-navbtn { + display: inline-grid; + place-items: center; + inline-size: 30px; + block-size: 30px; + border-radius: var(--radius-pill); + border: 1px solid var(--line); + background: var(--surface); + color: var(--ink-2); + cursor: pointer; +} +.cal-navbtn:hover { background: var(--hover); color: var(--ink); } +.cal-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; } +.cal-dow { margin-bottom: 4px; } +.cal-dow span { text-align: center; font-size: var(--text-xs); color: var(--muted); font-weight: 600; padding-block: 2px; } +.cal-cell { + position: relative; + aspect-ratio: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + border-radius: 10px; + font-size: var(--text-sm); + color: var(--ink); +} +.cal-cell.is-empty { background: transparent; } +.cal-cell.is-today { background: var(--accent); color: var(--on-accent); font-weight: 700; } +.cal-n { line-height: 1; } +.cal-dot { inline-size: 5px; block-size: 5px; border-radius: 50%; background: var(--accent); } +.cal-cell.is-today .cal-dot { background: var(--on-accent); } + +/* agenda timeline */ +.agenda-timeline { display: flex; flex-direction: column; gap: 16px; margin-top: 4px; } +.day-group { display: flex; flex-direction: column; gap: 8px; } +.day-group__head { display: flex; align-items: center; gap: 8px; font-size: var(--text-sm); color: var(--ink-2); } +.day-pill { + background: var(--accent-weak); + color: var(--accent-ink); + font-weight: 700; + padding: 3px 10px; + border-radius: var(--radius-pill); + font-size: var(--text-xs); +} +.day-group__count { color: var(--muted); } +.tl { display: flex; flex-direction: column; } +.tl-item { display: grid; grid-template-columns: 46px 18px 1fr; align-items: stretch; gap: 8px; } +.tl-time { font-size: var(--text-sm); color: var(--ink-2); padding-block-start: 14px; text-align: start; } +.tl-rail { position: relative; display: flex; justify-content: center; } +.tl-rail::before { content: ""; position: absolute; inset-block: 0; inline-size: 2px; background: var(--line); } +.tl-dot { + position: relative; + margin-block-start: 15px; + inline-size: 10px; + block-size: 10px; + border-radius: 50%; + background: var(--accent); + border: 2px solid var(--surface); +} +.tl-card { padding: 11px 13px; margin-block: 6px; } +.tl-card h4 { margin: 0; font-size: var(--text-base); font-weight: 700; color: var(--ink); } +.tl-card__meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 6px; } +.tl-owner { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-xs); color: var(--muted); } +.tl-owner .ic { width: 13px; height: 13px; } + +/* to-do checklist */ +.checklist { padding: 16px; } +.checklist__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; } +.checklist__head b { font-size: var(--text-base); color: var(--ink); font-weight: 700; } +.checklist__bar { block-size: 8px; border-radius: var(--radius-pill); background: var(--seg-off); overflow: hidden; margin-bottom: 14px; } +.checklist__bar > b { display: block; block-size: 100%; background: var(--accent); border-radius: var(--radius-pill); transition: inline-size 0.25s cubic-bezier(0.4, 0, 0.2, 1); } +.checklist__rows { display: flex; flex-direction: column; gap: 4px; } +.cl-row { display: flex; align-items: flex-start; gap: 11px; padding: 9px 4px; } +.cl-row.is-done { opacity: 0.55; } +.cl-box { + flex: none; + inline-size: 24px; + block-size: 24px; + border-radius: 50%; + border: 1.8px solid var(--line); + background: var(--surface); + color: transparent; + display: grid; + place-items: center; + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.cl-box .ic { width: 14px; height: 14px; } +.cl-box.is-on { background: var(--accent); border-color: var(--accent); color: var(--on-accent); } +.cl-row__body { flex: 1 1 auto; min-inline-size: 0; } +.cl-row__title { font-size: var(--text-base); color: var(--ink); } +.cl-row.is-done .cl-row__title { text-decoration: line-through; } +.cl-row__meta { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 5px; } + +/* desktop two-pane / two-column */ +@media (min-width: 900px) { + .split { display: grid; grid-template-columns: minmax(0, 1fr) 320px; align-items: start; } + .ppl-detail { position: sticky; inset-block-start: 18px; } + .duo { display: grid; grid-template-columns: 1fr 1fr; gap: 26px; align-items: start; } + .entity-nav { display: none; } +} + +@media (prefers-reduced-motion: reduce) { + .ppl-row, .cl-box, .checklist__bar > b { transition: none; } +}