diff --git a/src/db/migrations/1781267945750_add-chat-scope-muted.ts b/src/db/migrations/1781267945750_add-chat-scope-muted.ts new file mode 100644 index 0000000..a417779 --- /dev/null +++ b/src/db/migrations/1781267945750_add-chat-scope-muted.ts @@ -0,0 +1,20 @@ +import type { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate"; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +/** + * Per-chat MUTE — the third scope state (§7). A muted chat stays `included` (it + * keeps appearing in Updates/catch-up and is still summarized), but the daily + * suggestion engine skips it: no proactive suggestions or notifications. Default + * false so existing scopes are unchanged. Tenant isolation is inherited from the + * table's existing RLS policy — the column needs no policy of its own. + */ +export async function up(pgm: MigrationBuilder): Promise { + pgm.addColumn("chat_scopes", { + muted: { type: "boolean", notNull: true, default: false }, + }); +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropColumn("chat_scopes", "muted"); +} diff --git a/src/db/repositories/chat-scopes.test.ts b/src/db/repositories/chat-scopes.test.ts index 206080d..72100a6 100644 --- a/src/db/repositories/chat-scopes.test.ts +++ b/src/db/repositories/chat-scopes.test.ts @@ -2,7 +2,12 @@ import pg from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { NormalizedMessage } from "../../importer/types.js"; import { createTestDatabase } from "../../test/db.js"; -import { listIncludedGroupIds, listScopes, upsertScope } from "./chat-scopes.js"; +import { + listIncludedGroupIds, + listScopes, + listSuggestibleGroupIds, + upsertScope, +} from "./chat-scopes.js"; import { upsertGroup } from "./groups.js"; import { insertMessages } from "./messages.js"; import { upsertParticipant } from "./participants.js"; @@ -80,11 +85,46 @@ describe("chat-scopes repository", () => { }); describe("listScopes", () => { - it("projects an un-scoped group as excluded/uncategorized/not-removed (default-off)", async () => { + it("projects an un-scoped group as excluded/uncategorized/not-removed/not-muted (default-off)", async () => { await seedGroupWithMessage("cs-projection"); const row = (await listScopes(pool)).find((r) => r.group === "cs-projection")!; - expect(row).toMatchObject({ included: false, categoryId: null, removed: false }); + expect(row).toMatchObject({ + included: false, + categoryId: null, + removed: false, + muted: false, + }); expect(row.messageCount).toBe(1); }); }); + + describe("per-chat mute (§7 third state)", () => { + it("upsertScope round-trips muted and listScopes projects it", async () => { + const g = await seedGroupWithMessage("cs-mute"); + await upsertScope(pool, { groupId: g, included: true }); + expect((await listScopes(pool)).find((r) => r.group === "cs-mute")!.muted).toBe(false); + await upsertScope(pool, { groupId: g, muted: true }); + const row = (await listScopes(pool)).find((r) => r.group === "cs-mute")!; + // muting must not change inclusion — it still gets summarized. + expect(row).toMatchObject({ included: true, muted: true }); + await upsertScope(pool, { groupId: g, muted: false }); + expect((await listScopes(pool)).find((r) => r.group === "cs-mute")!.muted).toBe(false); + }); + + it("listSuggestibleGroupIds excludes muted chats but listIncludedGroupIds keeps them", async () => { + const plain = await seedGroupWithMessage("cs-sugg-plain"); + const muted = await seedGroupWithMessage("cs-sugg-muted"); + await upsertScope(pool, { groupId: plain, included: true }); + await upsertScope(pool, { groupId: muted, included: true, muted: true }); + + const suggestible = await listSuggestibleGroupIds(pool); + const included = await listIncludedGroupIds(pool); + + // muted chat is still summarized (included) ... + expect(included).toEqual(expect.arrayContaining([plain, muted])); + // ... but never produces suggestions. + expect(suggestible).toContain(plain); + expect(suggestible).not.toContain(muted); + }); + }); }); diff --git a/src/db/repositories/chat-scopes.ts b/src/db/repositories/chat-scopes.ts index 760bf3f..38ccdd7 100644 --- a/src/db/repositories/chat-scopes.ts +++ b/src/db/repositories/chat-scopes.ts @@ -9,6 +9,8 @@ export type ScopeRow = { included: boolean; categoryId: number | null; removed: boolean; + /** Muted chats stay in catch-up/Updates but generate no proactive suggestions. */ + muted: boolean; }; /** A single scope change (one chat). `removed` true→soft-remove, false→restore. */ @@ -17,6 +19,7 @@ export type ScopeUpdate = { included?: boolean; categoryId?: number | null; removed?: boolean; + muted?: boolean; }; /** @@ -33,6 +36,7 @@ export async function listScopes(client: pg.Pool | pg.PoolClient): Promise( ` SELECT g.name, @@ -41,11 +45,12 @@ export async function listScopes(client: pg.Pool | pg.PoolClient): Promise Number(r.id)); } + +/** + * The suggestion filter: included group ids that are NOT muted. Muted chats are + * still summarized (they stay in `listIncludedGroupIds`) but produce no proactive + * suggestions — the §7 "third state". A strict subset of `listIncludedGroupIds`. + */ +export async function listSuggestibleGroupIds(client: pg.Pool | pg.PoolClient): Promise { + const { rows } = await client.query<{ id: string }>( + ` + SELECT g.id + FROM groups g + JOIN chat_scopes cs ON cs.group_id = g.id + WHERE cs.included AND cs.removed_at IS NULL AND NOT cs.muted + ORDER BY g.id ASC + `, + ); + return rows.map((r) => Number(r.id)); +} diff --git a/src/db/repositories/groups.ts b/src/db/repositories/groups.ts index 9bf6d80..c36c753 100644 --- a/src/db/repositories/groups.ts +++ b/src/db/repositories/groups.ts @@ -168,17 +168,34 @@ export async function upsertGroupByWhatsappId( * messages (last_message_at IS NULL) sink to the bottom; name is the tiebreaker * so equal-recency chats stay in a stable, predictable order. */ -export async function listGroups( - client: pg.Pool | pg.PoolClient, -): Promise<{ name: string; source: string; messageCount: number; lastMessageAt: Date | null }[]> { +export async function listGroups(client: pg.Pool | pg.PoolClient): Promise< + { + name: string; + source: string; + messageCount: number; + lastMessageAt: Date | null; + newCount: number; + }[] +> { const { rows } = await client.query<{ name: string; source: string; message_count: string; last_message_at: Date | null; + new_count: string; }>( ` - SELECT g.name, g.source, COUNT(m.id) AS message_count, MAX(m.sent_at) AS last_message_at + SELECT g.name, g.source, + COUNT(m.id) AS message_count, + MAX(m.sent_at) AS last_message_at, + -- "חדשות": messages that arrived since this chat was last summarized + -- (an un-summarized chat counts all its messages as new to catch up on). + COUNT(m.id) FILTER ( + WHERE m.sent_at > COALESCE( + (SELECT MAX(s.created_at) FROM summaries s WHERE s.group_id = g.id), + '-infinity'::timestamptz + ) + ) AS new_count FROM groups g LEFT JOIN messages m ON m.group_id = g.id GROUP BY g.id, g.name, g.source @@ -190,6 +207,7 @@ export async function listGroups( source: r.source, messageCount: Number(r.message_count), lastMessageAt: r.last_message_at ?? null, + newCount: Number(r.new_count), })); } diff --git a/src/web/handlers/scopes.ts b/src/web/handlers/scopes.ts index 180affb..3d76d6e 100644 --- a/src/web/handlers/scopes.ts +++ b/src/web/handlers/scopes.ts @@ -64,6 +64,7 @@ async function getScopes(res: http.ServerResponse, deps: ServerDeps): Promise !s.included || s.removed).map((s) => s.group), - ); - groups = groups.filter((g) => !excluded.has(g.name)); + const byName = new Map((await getScopes()).map((s) => [s.group, s])); + groups = groups + .filter((g) => { + const s = byName.get(g.name); + return s ? s.included && !s.removed : false; + }) + .map((g) => ({ ...g, categoryId: byName.get(g.name)?.categoryId ?? null })); } catch { /* show all on scope-load failure */ } @@ -424,6 +430,13 @@ async function renderCatchup() { paneMain.innerHTML = `

טוען עדכונים…

`; if (cachedGroups.length === 0 && !DEMO) await loadGroupsIntoList(); + if (!DEMO && catchupCategories.length === 0) { + try { + catchupCategories = await getScopeCategories(); + } catch { + /* chips fall back to just "הכול" */ + } + } const groups = cachedGroups; if (!groups.length) { @@ -433,24 +446,55 @@ async function renderCatchup() {
${icon("filter", { size: 26 })}

אין צ׳אטים מוזנים

בחרו אילו שיחות יזינו את CatchApp כדי לקבל עדכונים.

- + `; document.getElementById("catchup-manage")?.addEventListener("click", () => navigate("sources")); return; } - const cards = groups.map((g) => buildUpdateCard(g)).join(""); + // Category chips: "הכול" + any category that has at least one included chat. + const usedCatIds = new Set(groups.map((g) => g.categoryId).filter((id) => id != null)); + const catChips = catchupCategories + .filter((c) => usedCatIds.has(c.id)) + .map((c) => ({ name: c.name, id: c.id })); + const filterNames = ["הכול", ...catChips.map((c) => c.name)]; + if (!filterNames.includes(catchupFilter)) catchupFilter = "הכול"; + + const visible = + catchupFilter === "הכול" + ? groups + : groups.filter((g) => catChips.find((c) => c.name === catchupFilter)?.id === g.categoryId); + + const chips = filterNames + .map((n) => `${escHtml(n)}`) + .join(""); + + const listOrEmpty = + visible.length === 0 + ? `
+
${icon("inbox", { size: 26 })}
+

אין עדכונים בקטגוריה זו

+

נסו קטגוריה אחרת, או הוסיפו עוד צ׳אטים לקבוצה הזו.

+
` + : `
${visible.map((g) => buildUpdateCard(g)).join("")}
`; + paneMain.innerHTML = `
- קבץ לפי: - הכול + קבץ לפי: + ${chips} ${icon("filter", { size: 14 })}נהל צ׳אטים
-
${cards}
+ ${listOrEmpty}
`; document.getElementById("catchup-manage")?.addEventListener("click", () => navigate("sources")); + paneMain.querySelector(".filters")?.addEventListener("click", (e) => { + const ch = e.target.closest(".chip[data-filter]"); + if (!ch) return; + catchupFilter = ch.dataset.filter; + renderCatchup(); + }); for (const card of paneMain.querySelectorAll(".itemcard[data-group]")) { card.addEventListener("click", () => navigate("detail", card.dataset.group)); } @@ -460,17 +504,16 @@ function buildUpdateCard(g) { const name = formatGroupName(g.name); const hue = g.hue ?? hueFromName(g.name); const sum = g.sum || "הקישו לסיכום מה שפספסתם בשיחה הזו."; - const newBadge = g.n ? `${g.n} חדשות` : ""; - const who = g.who || (g.messageCount != null ? `${g.messageCount} הודעות` : ""); + const n = g.newCount ?? g.n; + const newBadge = n ? `${n} חדשות` : ""; const ago = g.lastMessageAt ? formatAgo(g.lastMessageAt) : ""; - const meta = [who, ago].filter(Boolean).join(" · "); return `
${avatarHtml(name, hue, 40)}

${escHtml(name)}${newBadge}

${escHtml(sum)}

- ${meta ? `
${escHtml(meta)}
` : ""} + ${ago ? `
${escHtml(ago)}
` : ""}
פתח ${icon("chevL", { size: 15 })}
`; @@ -849,34 +892,43 @@ function onError(data) { /* ── 5d. Phase Tube + summary builders ───────────────────── */ /** - * Liquid Phase Tube — phase-aware loader. - * @param {{ phase: string, messages?: number, elapsed?: number }} opts + * The playful "summarizing" loader (.sumload): a bobbing brand glyph in a + * pulsing ring, orbiting dots, rising chat bubbles, twinkling sparkles and an + * indeterminate bar. All motion is CSS and gated behind prefers-reduced-motion. */ -function buildPhaseTube({ phase = "sync", messages = 0, elapsed = 0 } = {}) { - const fill = phaseFill(phase); - const active = activeZoneIndex(phase); - const caption = phaseCaption(phase, { messages }); - const elapsedStr = elapsed > 0 ? `${elapsed}ש׳` : ""; - // Labels render in phase order; RTL places the first (סנכרון) on the right. - const labels = PHASES.map((p, i) => - `${PHASE_LABELS[p]}` - ).join(""); +function buildSumLoader(title, quip, compact = false) { return ` -
- -
- ${escHtml(caption)} - ${escHtml(elapsedStr)} -
- `; +} + +/** + * Summarize loader (phase-aware copy). Name + signature are kept so existing + * call sites — and the now no-op tube updaters — need no change; the retired + * Glacier "phase tube" is replaced by the designed .sumload scene. + * @param {{ phase?: string }} opts + */ +function buildPhaseTube({ phase = "sync" } = {}) { + const copy = { + sync: ["מתחבר לוואטסאפ…", "טוען את ההודעות האחרונות…"], + read: ["קורא את ההודעות…", "עובר על מה שפספסת…"], + summarize: ["בונה את הסיכום…", "מתמצת לכמה שורות ✦"], + done: ["מסיים…", "כמעט שם ✦"], + }; + const [title, quip] = copy[phase] || copy.sync; + return buildSumLoader(title, quip); } /** Update the live elapsed counter inside the tube. */ @@ -926,7 +978,9 @@ function buildSummaryCardDone(text, statusText, stale) { function renderSumBullets(bullets) { return bullets .map((b) => { - const text = escHtml(b.text); + // Inline markdown (bold label + chat tags), citation markers stripped — + // the source-jump button carries the real messageId for attribution. + const text = renderInline(b.text); if (b.sourceMessageId) { return `
  • ` ).join(""); return `
    ${items}
    `; @@ -1547,8 +1605,14 @@ function paintSources() {
    צ׳אטים
  • -

    אתם בוחרים מה CatchApp רואה · - ${counts.active}/${counts.total} פעילים

    +
    + ${icon("filter", { size: 22 })} +
    + אתם בוחרים מה CatchApp רואה +

    רק צ׳אטים מסומנים מוזנים לסיכום, לעדכונים ולהצעות. תייגו לפי הקשר כדי לכוון את המערכת.

    +
    + ${counts.active}/${counts.total} פעילים +
    @@ -1565,6 +1629,7 @@ function paintSources() { ${sections.map(buildSourcesSection).join("")} ${filtered.length === 0 ? `

    לא נמצאו צ׳אטים תואמים.

    ` : ""} ${removed.length ? buildRemovedSection(removed) : ""} +

    מתג = הכללה/החרגה · ✕ = הסרה · ירח = השתקת הצעות · ״קבוצה״ ליצירת קטגוריה

    `; wireSources(); } @@ -1574,17 +1639,22 @@ function buildSourcesSection(section) { const n = sectionCount(section.scopes); const anyIncluded = section.scopes.some((s) => s.included); const bulkLabel = anyIncluded ? "כבה הכול" : "הפעל הכול"; + const rows = section.scopes.map((s, i) => (i ? '
    ' : "") + buildSourceRow(s)).join(""); return `
    ${title} ${n} ${section.scopes.length ? `` : ""}
    - ${section.scopes.map(buildSourceRow).join("") || `

    אין צ׳אטים בקטגוריה זו

    `} + ${section.scopes.length ? `
    ${rows}
    ` : `

    אין צ׳אטים בקטגוריה זו

    `}
    `; } function buildSourceRow(s) { + const name = formatGroupName(s.group); + const catName = sourcesState.categories.find((c) => c.id === s.categoryId)?.name; + const status = !s.included ? "מוחרג — לא ינוטר" : s.muted ? "מושתק · עדכונים בלבד" : "מוזן ל-CatchApp"; + const statusLine = catName ? `${escHtml(status)} · ${escHtml(catName)}` : escHtml(status); const cats = sourcesState.categories .map( (c) => @@ -1592,20 +1662,29 @@ function buildSourceRow(s) { ) .join(""); return ` -
    - +
    + ${avatarHtml(name, hueFromName(s.group), 38)}
    -
    ${escHtml(formatGroupName(s.group))}
    -
    ${s.messageCount}
    +
    ${escHtml(name)}
    +
    ${statusLine}
    + ${ + s.included + ? `` + : "" + } +
    `; } @@ -1633,6 +1712,7 @@ async function applyScopeChange(updates) { if (u.included !== undefined) row.included = u.included; if (u.categoryId !== undefined) row.categoryId = u.categoryId; if (u.removed !== undefined) row.removed = u.removed; + if (u.muted !== undefined) row.muted = u.muted; } paintSources(); try { @@ -1687,6 +1767,10 @@ function wireSources() { const s = sourcesState.scopes.find((x) => x.group === group); applyScopeChange([{ group, included: !s.included }]); }); + row.querySelector('[data-act="mute"]')?.addEventListener("click", () => { + const s = sourcesState.scopes.find((x) => x.group === group); + applyScopeChange([{ group, muted: !s.muted }]); + }); row.querySelector('[data-act="remove"]')?.addEventListener("click", () => applyScopeChange([{ group, removed: true }]), ); @@ -1704,6 +1788,77 @@ function wireSources() { const settingsState = { prefs: null }; +/** Morning-notification preview (§8): a lock-screen push mock overlaid on the + * main column. Dismiss by tapping the backdrop, the "סגירה" button, or Esc. */ +function showNotifPreview() { + const host = document.querySelector(".main"); + if (!host || document.getElementById("notif-preview")) return; + const el = document.createElement("div"); + el.className = "notif-preview"; + el.id = "notif-preview"; + el.setAttribute("role", "dialog"); + el.setAttribute("aria-label", "תצוגה מקדימה של התראת הבוקר"); + el.innerHTML = ` +
    +
    + ${brandGlyph(38)} +
    +
    CatchAppעכשיו
    +
    הסיכום של היום מוכן ✦
    +
    5 דברים מחכים לך · קריאה של דקה.
    +
    +
    + +
    `; + const close = () => { + el.remove(); + document.removeEventListener("keydown", onKey); + }; + const onKey = (e) => { if (e.key === "Escape") close(); }; + // Backdrop click or "סגירה" closes; clicks inside the card do not. + el.addEventListener("click", (e) => { + if (e.target === el || e.target.closest(".notif-dismiss")) close(); + }); + document.addEventListener("keydown", onKey); + host.appendChild(el); +} + +/** A centered warn-tinted confirm dialog (§8 delete-everything). Calls onConfirm + * when the danger button is pressed; dismisses on backdrop / cancel / Esc. */ +function showConfirm({ title, body, confirmLabel, onConfirm }) { + const host = document.querySelector(".main"); + if (!host || document.getElementById("confirm-overlay")) return; + const el = document.createElement("div"); + el.className = "notif-preview"; + el.id = "confirm-overlay"; + el.setAttribute("role", "dialog"); + el.setAttribute("aria-label", title); + el.innerHTML = ` +
    +
    ${icon("trash", { size: 22 })}
    + ${escHtml(title)} +

    ${escHtml(body)}

    +
    + + +
    +
    `; + const close = () => { + el.remove(); + document.removeEventListener("keydown", onKey); + }; + const onKey = (e) => { if (e.key === "Escape") close(); }; + el.addEventListener("click", (e) => { + if (e.target === el || e.target.closest('[data-confirm="cancel"]')) return close(); + if (e.target.closest('[data-confirm="ok"]')) { + close(); + onConfirm?.(); + } + }); + document.addEventListener("keydown", onKey); + host.appendChild(el); +} + /** The Settings screen (§8): privacy callout, daily digest, display mode, * and the experimental suggestion-engine config. Fetch-on-entry, then paint. */ async function renderSettings() { @@ -1790,6 +1945,44 @@ function paintSettings() {
    +

    מנוע ההצעות ניסיוני

    +
    +
    + ${icon("sparkle", { cls: "set-ico" })} +

    הצעות חכמות בסיכום

    זיהוי משימות, פגישות ופולואו-אפים מהשיחות. בשלב ניסיוני — בשליטתך המלאה.

    + +
    + ${ + engine.on + ? ` +
    +
    +

    אילו הצעות להציג

    כבו סוג כדי שלא יופיע בסיכום

    +
    +
    ${kindChips}
    +
    +
    + ${icon("bolt", { cls: "set-ico" })} +

    רמת יוזמה

    כמה הצעות המנוע יציע ביום

    +
    ${proactSeg}
    +
    +
    +
    + ${icon("filter", { cls: "set-ico" })} +

    צ׳אטים מוזנים

    בחרו אילו שיחות המנוע ינתח

    + +
    +
    +
    + ${icon("sparkle", { cls: "set-ico" })} +

    איפוס למידה

    אפסו את ההעדפות שנלמדו והתחילו מחדש

    + +
    ` + : "" + } +
    +

    פרטיות ונתונים

    @@ -1807,7 +2000,7 @@ function paintSettings() {
    ${icon("trash", { cls: "set-ico" })}

    נתק וואטסאפ ומחק הכול

    הסרה מלאה של הנתונים מהמכשיר

    - +
    @@ -1816,6 +2009,7 @@ function paintSettings() {
    ${icon("bell", { cls: "set-ico" })}

    התראת בוקר

    תזכורת עדינה כשהסיכום מוכן

    +
    @@ -1835,38 +2029,6 @@ function paintSettings() {
    ${themeSeg}
    - -

    מנוע ההצעות ניסיוני

    -
    -
    - ${icon("sparkle", { cls: "set-ico" })} -

    הצעות חכמות בסיכום

    זיהוי משימות, פגישות ופולואו-אפים מהשיחות. בשלב ניסיוני — בשליטתך המלאה.

    - -
    - ${ - engine.on - ? ` -
    -
    -

    אילו הצעות להציג

    כבו סוג כדי שלא יופיע בסיכום

    -
    -
    ${kindChips}
    -
    -
    - ${icon("bolt", { cls: "set-ico" })} -

    רמת יוזמה

    כמה הצעות המנוע יציע ביום

    -
    ${proactSeg}
    -
    -
    -
    - ${icon("filter", { cls: "set-ico" })} -

    צ׳אטים מוזנים

    בחרו אילו שיחות המנוע ינתח

    - -
    ` - : "" - } -
    `; wireSettings(); } @@ -1888,6 +2050,9 @@ function wireSettings() { ?.addEventListener("click", () => applyPrefChange({ morningNotification: !prefs.morningNotification }), ); + document + .querySelector('[data-act="notif-preview"]') + ?.addEventListener("click", showNotifPreview); // Display mode — localStorage is the source of truth (lib/theme.js); we also // mirror the choice into prefs so a fresh device can pick it up. @@ -1919,6 +2084,38 @@ function wireSettings() { applyPrefChange({ engineConfig: { ...engine, proact: btn.dataset.val } }), ); } + document.querySelector('[data-act="engine-reset"]')?.addEventListener("click", (e) => { + const btn = e.currentTarget; + btn.disabled = true; + resetSuggestionLearning() + .then(() => { + btn.textContent = "אופס ✓"; + }) + .catch(() => { + btn.textContent = "לא הצליח"; + }) + .finally(() => setTimeout(() => { btn.textContent = "אפס"; btn.disabled = false; }, 1800)); + }); + document.querySelector('[data-act="wipe"]')?.addEventListener("click", () => { + showConfirm({ + title: "למחוק הכול?", + body: "ניתוק וואטסאפ ומחיקת כל ההודעות, הסיכומים והנתונים מהמכשיר. אי אפשר לבטל.", + confirmLabel: "מחק הכול", + // No server-side wipe endpoint yet — be honest rather than fake success. + onConfirm: () => showMainToast("המחיקה עדיין לא זמינה — נתקו ידנית בהגדרות וואטסאפ"), + }); + }); +} + +/** A transient toast pinned to the bottom of the main column (reuses .dg-flash). */ +function showMainToast(text) { + const host = document.querySelector(".main"); + if (!host) return; + const t = document.createElement("div"); + t.className = "dg-flash show"; + t.textContent = text; + host.appendChild(t); + setTimeout(() => t.remove(), 2400); } /** Apply + persist a theme choice and keep the appbar toggle icon in sync. */ @@ -1999,7 +2196,14 @@ async function renderToday() { teardownStream(); setView("today"); setAppbar("today"); - paneMain.innerHTML = `

    טוען את הסיכום…

    `; + // Re-paint Today when the viewport crosses the board/stack breakpoint (once). + if (!todayState.mediaWired && window.matchMedia) { + todayState.mediaWired = true; + window.matchMedia("(min-width: 780px)").addEventListener("change", () => { + if (layout?.dataset.view === "today") paintToday(); + }); + } + paneMain.innerHTML = `
    ${buildSumLoader("בונה את הסיכום היומי…", "עובר על הצ׳אטים שבחרת ✦")}
    `; let data = null; try { @@ -2045,24 +2249,30 @@ function paintToday() { const total = todayState.deck.length; const hasSuggestionsLeft = todayState.deck.some(isSuggestion); - let body; - if (total === 0) { - body = todayState.acted > 0 ? buildDoneState(todayState.tally) : buildEmptyToday(todayState.engineOn); - } else if (!hasSuggestionsLeft && todayState.acted > 0) { - body = buildDoneState(todayState.tally); + // Desktop ≥780px = the web-native digest board (v2); narrower = the v1 phone + // Stories stack. Both share the deck + onTodayAct; only the hero markup differs. + let hero; + if (isBoardLayout()) { + hero = buildDigestBoard(total, hasSuggestionsLeft); } else { - body = buildStoryStack(); + let body; + if (total === 0) { + body = todayState.acted > 0 ? buildDoneState(todayState.tally) : buildEmptyToday(todayState.engineOn); + } else if (!hasSuggestionsLeft && todayState.acted > 0) { + body = buildDoneState(todayState.tally); + } else { + body = buildStoryStack(); + } + hero = ` +
    + ${buildTodayHeader(new Date())} +
    ${icon("sparkle", { size: 13 })}הצעות חכמות שנבנות מהשיחות — ומתחדדות לפי הבחירות שלך
    + ${body} +
    ${buildTodayFoot(total, hasSuggestionsLeft)}
    + ${buildTiles()} +
    `; } - const hero = ` -
    - ${buildTodayHeader(new Date())} -
    ${icon("sparkle", { size: 13 })}הצעות חכמות שנבנות מהשיחות — ומתחדדות לפי הבחירות שלך
    - ${body} -
    ${buildTodayFoot(total, hasSuggestionsLeft)}
    - ${buildTiles()} -
    `; - paneMain.innerHTML = `
    @@ -2155,6 +2365,107 @@ function buildCommandSide() {
    `; } +/** True when the viewport is wide enough for the desktop digest board (v2). */ +function isBoardLayout() { + return window.matchMedia?.("(min-width: 780px)").matches ?? true; +} + +/** The v2 board header: kicker, greeting, an inline date + remaining-count + * subline, and the personalization note pill. */ +function buildDigestHead(now, left) { + let dateStr = ""; + try { + const wd = now.toLocaleDateString("he-IL", { weekday: "long" }); + dateStr = `${escHtml(wd)} · ${now.getDate()}.${now.getMonth() + 1}`; + } catch { + /* leave the date blank if Intl is unavailable */ + } + const countBit = + left > 0 + ? `·${left} ${left === 1 ? "כרטיס" : "כרטיסים"} להתעדכן · קריאה של דקה` + : ""; + return ` +
    +
    הסיכום היומי
    +
    ${escHtml(greeting(now.getHours()))}
    +
    ${dateStr}${countBit}
    +
    ${icon("sparkle", { size: 13 })}הצעות חכמות שנבנות מהשיחות — ומתחדדות לפי הבחירות שלך
    +
    `; +} + +/** One wide digest-board row — a suggestion (edit-first + act buttons) or a + * read-only info card. Acted on in place via onTodayAct (shared with the stack). */ +function buildDigestCard(card) { + if (isSuggestion(card)) { + const cfg = suggestionConfig(card.kind); + const draftVal = card.draft ?? card.proposedText; + let editor; + if (cfg.editable) { + editor = `
    ${icon("pencil", { size: 15 })}
    `; + } else { + const lines = String(card.proposedText).split("\n").map((s) => s.trim()).filter(Boolean); + const items = (lines.length ? lines : [card.proposedText]).map((l) => `
  • ${escHtml(l)}
  • `).join(""); + editor = `
      ${items}
    `; + } + return ` +
    +
    + ${icon(cfg.icon, { size: 18 })} +
    +
    ${escHtml(cfg.kicker)} · מותאם אישית
    +

    ${escHtml(cfg.title(formatGroupName(card.chat)))}

    +
    + ${icon("sparkle", { size: 11 })}טיוטה +
    + ${editor} +
    ${icon("bolt", { size: 15 })}${escHtml(card.reason)}
    +
    + ${buildSrcChip(card)} +
    + + + +
    +
    +
    `; + } + const isHi = card.variant === "highlights"; + const title = isHi ? "עיקרי היום בכל הצ׳אטים" : formatGroupName(card.chat); + const kicker = isHi ? "מבט על היום" : "סיכום צ׳אט"; + return ` +
    +
    + ${icon(isHi ? "sparkle" : "message", { size: 18 })} +
    +
    ${escHtml(kicker)}
    +

    ${escHtml(title)}

    +
    + מידע +
    +
    ${renderMarkdown(card.body)}
    +
    ${buildSrcChip(card)}
    +
    `; +} + +/** The desktop digest board (v2): head + a flat column of wide cards (or the + * done / empty state), plus the flash slot. */ +function buildDigestBoard(total, hasSuggestionsLeft) { + let body; + if (total === 0) { + body = todayState.acted > 0 ? buildDoneState(todayState.tally) : buildEmptyToday(todayState.engineOn); + } else if (!hasSuggestionsLeft && todayState.acted > 0) { + body = buildDoneState(todayState.tally); + } else { + body = `
    ${todayState.deck.map(buildDigestCard).join("")}
    `; + } + return ` +
    + ${buildDigestHead(new Date(), total)} + ${body} +
    +
    `; +} + /** The Stories stack: peek cards behind + the active card + the flash slot. */ function buildStoryStack() { const i = clampIndex(todayState.index, todayState.deck.length); @@ -2234,7 +2545,7 @@ function buildInfoCard(card, index, total) { מידע

    ${escHtml(title)}

    -
    ${escHtml(card.body)}
    +
    ${renderMarkdown(card.body)}
    `; // Read-only: no accept/snooze/discard — just a hint to swipe on. const footer = `
    ${icon("sparkle", { size: 14 })}סקירה — החליקו להמשך
    `; @@ -2246,9 +2557,9 @@ function buildInfoCard(card, index, total) { function buildSrcChip(card) { const label = escHtml(formatGroupName(card.chat)); if (card.sourceMessageId == null) { - return `${icon("arrowL", { size: 13 })}${label}`; + return `${icon("source", { size: 13 })}${label}`; } - return ``; + return ``; } function buildDoneState(tally) { @@ -2312,6 +2623,33 @@ function wireToday() { el.addEventListener("click", () => navigate(el.dataset.go)); } + // Desktop digest board: act buttons (commit/snooze/discard), per-card draft + // preservation, and source-chip jumps. Shares onTodayAct with the stack. + const board = document.querySelector(".dg-board"); + if (board) { + for (const card of board.querySelectorAll(".dg-card[data-card-id]")) { + const id = Number(card.dataset.cardId); + card.querySelector(".sg-draft__input")?.addEventListener("input", (e) => { + const c = todayState.deck.find((x) => x.id === id); + if (c && isSuggestion(c)) c.draft = e.target.value; + }); + } + for (const btn of board.querySelectorAll("[data-act]")) { + btn.addEventListener("click", (e) => { + e.stopPropagation(); + onTodayAct(btn.dataset.act, Number(btn.dataset.id)); + }); + } + for (const chip of board.querySelectorAll("[data-src-jump]")) { + chip.addEventListener("click", (e) => { + e.stopPropagation(); + const cid = Number(chip.dataset.id); + if (chip.dataset.chat && Number.isFinite(cid)) navigate("thread", { chat: chip.dataset.chat, aroundId: cid }); + }); + } + return; + } + const stack = document.querySelector(".today .story-stack"); if (!stack) return; @@ -2360,7 +2698,9 @@ function onTodayAct(act, id) { let finalText; let flash; if (act === "commit") { - const input = document.querySelector(".today .sg-draft__input"); + const input = + document.querySelector(`[data-card-id="${id}"] .sg-draft__input`) || + document.querySelector(".today .sg-draft__input"); const draftValue = input ? input.value : (card.draft ?? card.proposedText); const res = commitActionFor(card, draftValue); action = res.action; @@ -2434,9 +2774,9 @@ 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 `${icon("source", { size: 13 })}${text}`; } - return ``; + return ``; } /** Delegate source-chip clicks within a container to the S2 thread jump. */ @@ -2495,14 +2835,11 @@ function paintPeople() { 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}
    @@ -2529,7 +2866,7 @@ function buildPersonRow(p, isSel) { ${escHtml(meta.label)}

    ${note}

    -
    קשר אחרון · ${escHtml(last)}${open}
    +
    ${escHtml(last)}${open}
    `; } @@ -2556,7 +2893,7 @@ function buildPersonDetail(p) {
    ${icon("message", { size: 16 })}שיחות פתוחות${p.openThreads}
    -
    ${icon("target", { size: 13 })} הצעד הבא
    +
    הצעד הבא
    ${p.nextStep ? escHtml(p.nextStep) : "אין צעד פתוח כרגע"} ${p.nextStep ? chip : ""} @@ -2616,6 +2953,8 @@ async function renderAgenda() { agendaState.meetings = Array.isArray(meetings) ? meetings : []; agendaState.todos = Array.isArray(todos) ? todos : []; agendaState.monthOffset = 0; + setNavCount("meetings", agendaState.meetings.length); + setNavCount("todos", agendaState.todos.filter((t) => !t.done).length); paintAgenda(); } diff --git a/src/web/public/lib/api.js b/src/web/public/lib/api.js index 7138082..a94c170 100644 --- a/src/web/public/lib/api.js +++ b/src/web/public/lib/api.js @@ -227,6 +227,17 @@ export function createScopeCategory(name) { }); } +/** + * Wipe the suggestion engine's learned feedback bias (§8 "איפוס למידה"). + * @returns {Promise} + */ +export function resetSuggestionLearning() { + return fetch("/api/suggestions/reset-learning", { method: "POST" }).then((r) => { + if (!r.ok) throw new Error(`resetSuggestionLearning ${r.status}`); + return r.json().catch(() => ({})); + }); +} + /** * @typedef {{ * digestTimes: string, diff --git a/src/web/public/lib/markdown.js b/src/web/public/lib/markdown.js index 98d8740..efd37c5 100644 --- a/src/web/public/lib/markdown.js +++ b/src/web/public/lib/markdown.js @@ -51,6 +51,35 @@ function applyInline(text) { return bolded.replace(/\[([^\]\n]+)\]/g, '$1'); } +/** + * Strip inline source-citation markers the model emits, e.g. `^[#3, #5]` or + * `[#1], [#2]`. The product surfaces sources via the tappable `.src` / `.sum-jump` + * affordance (which carries the real messageId), so the raw `[#n]` numbers are + * noise that the design never shows — drop them, including any leading caret and + * the comma joiners between a run of them. Only `#`-prefixed brackets are touched, + * so chat tags (`[Bar Hevr]`) and other brackets survive. + * + * @param {string} text - already HTML-escaped text + * @returns {string} + */ +function stripCitations(text) { + return text.replace(/\s*\^?\s*\[#[^\]\n]*\](?:\s*,?\s*\^?\s*\[#[^\]\n]*\])*/g, ""); +} + +/** + * Render a single line of inline Markdown (bold + chat tags) to safe HTML, with + * citation markers stripped. Use for text that lives inside its own element (a + * summary bullet `
  • `, a card line) where the block-level wrapping of + * {@link renderMarkdown} (`

    `/`

      `) would be wrong. + * + * @param {string} text - raw single-line text (may contain model output) + * @returns {string} - safe inline HTML; empty string for empty input + */ +export function renderInline(text) { + if (text == null || String(text).trim() === "") return ""; + return applyInline(stripCitations(escapeHtml(String(text)))); +} + /** * Render a block of lines (no blank-line gaps within) to HTML. * The lines are already HTML-escaped. @@ -106,8 +135,12 @@ export function renderMarkdown(md) { // 1. Escape HTML FIRST — nothing from md reaches the DOM as raw markup const escaped = escapeHtml(md); - // 2. Split into blank-line-separated blocks - const rawBlocks = escaped.split(/\n{2,}/); + // 2. Drop inline source-citation markers (`^[#3, #5]`) — the source-jump + // affordance carries the real messageId, so these raw numbers are noise. + const cleaned = stripCitations(escaped); + + // 3. Split into blank-line-separated blocks + const rawBlocks = cleaned.split(/\n{2,}/); const parts = []; diff --git a/src/web/public/lib/markdown.test.js b/src/web/public/lib/markdown.test.js index 262f2a3..72be93b 100644 --- a/src/web/public/lib/markdown.test.js +++ b/src/web/public/lib/markdown.test.js @@ -5,7 +5,7 @@ */ import { describe, it, expect } from "vitest"; -import { renderMarkdown } from "./markdown.js"; +import { renderInline, renderMarkdown } from "./markdown.js"; describe("renderMarkdown — headings", () => { it("## heading →

      ", () => { @@ -142,6 +142,56 @@ describe("renderMarkdown — empty / whitespace input", () => { }); }); +describe("renderMarkdown — citation markers", () => { + it("strips a single `^[#3, #5]` marker", () => { + const out = renderMarkdown("גיא פרסם את לוח הזמנים ^[#3, #5]."); + expect(out).not.toContain("#3"); + expect(out).not.toContain("[#"); + expect(out).toContain("לוח הזמנים"); + }); + + it("strips a run of separate `[#1], [#2]` markers", () => { + const out = renderMarkdown("אין עליו מפתח [#1], [#2]."); + expect(out).not.toContain("[#"); + expect(out).not.toContain("#2"); + }); + + it("keeps chat tags (no #) intact", () => { + const out = renderMarkdown("- [Bar Hevr] עדכון ^[#7]"); + expect(out).toContain('Bar Hevr'); + expect(out).not.toContain("[#"); + }); +}); + +describe("renderInline", () => { + it("renders **bold** without block wrapping", () => { + const out = renderInline("**זמינות לעזרה:** אייל עדכן"); + expect(out).toContain("זמינות לעזרה:"); + expect(out).not.toContain("

      "); + expect(out).not.toContain("

        "); + }); + + it("strips inline citation markers", () => { + const out = renderInline("אין עליו מפתח ^[#1], [#2]."); + expect(out).not.toContain("[#"); + expect(out).not.toContain("**"); + expect(out).toContain("אין עליו מפתח"); + }); + + it("escapes HTML before transforming", () => { + const out = renderInline("x **y**"); + expect(out).not.toContain("x"); + expect(out).toContain("<b>"); + expect(out).toContain("y"); + }); + + it("empty / null → empty string", () => { + expect(renderInline("")).toBe(""); + expect(renderInline(null)).toBe(""); + expect(renderInline(" ")).toBe(""); + }); +}); + describe("renderMarkdown — realistic mixed sample", () => { it("heading + bullets + paragraph all render correctly", () => { const md = [ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index f714b67..9bf6854 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -284,8 +284,8 @@ body::after { align-items: flex-start; gap: 9px; padding: 13px 16px; - background: rgba(251, 146, 60, 0.12); - border-bottom: 1px solid rgba(251, 146, 60, 0.50); + background: var(--warn-weak); + border-bottom: 1px solid var(--warn); color: var(--warn-ink); font-size: var(--text-base); line-height: 1.5; @@ -2617,13 +2617,17 @@ body::after { .sources-head { display: flex; align-items: center; gap: 10px; } .sources-head__title { font-weight: 700; color: var(--ink); font-size: var(--text-lg); } .sources-callout { - font-size: var(--text-sm); - color: var(--ink-2); + display: flex; + gap: 13px; + align-items: flex-start; background: var(--accent-weak); - border: 1px solid var(--line); border-radius: var(--radius-md); - padding: 8px 12px; + padding: 14px 16px; } +.sources-callout__ico { color: var(--accent-ink); flex: none; margin-block-start: 1px; } +.sources-callout b { color: var(--accent-ink); font-size: var(--text-md); } +.sources-callout p { margin: 3px 0 0; font-size: var(--text-base); color: var(--ink-2); line-height: 1.4; } +.sources-callout .badge.accent { align-self: center; white-space: nowrap; } .sources-toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .src-search { flex: 1 1 140px; @@ -2666,26 +2670,29 @@ body::after { .src-bulk { font: inherit; font-size: var(--text-xs); border: 0; background: transparent; color: var(--accent-ink); cursor: pointer; } .src-empty-cat { font-size: var(--text-sm); color: var(--muted); padding-inline-start: 4px; } +/* One surface card per category, hairline dividers between rows. */ +.src-card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-lg, 16px); overflow: visible; } +.divide { height: 1px; background: var(--line); } .src-row { display: flex; align-items: center; - gap: 10px; - padding: 8px 10px; - border: 1px solid var(--line); - border-radius: var(--radius-md); - background: var(--surface); + gap: 13px; + padding: 14px 16px; } +.src-row--off { opacity: 0.55; } +.src-row--off .src-row__name { color: var(--ink-2); } .src-row__body { flex: 1 1 auto; min-inline-size: 0; } -.src-row__name { color: var(--ink); font-size: var(--text-base); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.src-row__meta { font-size: var(--text-xs); color: var(--muted); } +.src-row__name { color: var(--ink); font-size: var(--text-base); font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.src-row__status { font-size: var(--text-sm); color: var(--muted); margin-block-start: 2px; } +.src-legend { font-size: var(--text-xs); color: var(--muted); text-align: center; margin: 8px 0 0; } .src-switch { flex: 0 0 auto; - inline-size: 40px; - block-size: 24px; + inline-size: 42px; + block-size: 25px; border-radius: var(--radius-pill); border: 1px solid var(--line); - background: var(--seg-off); + background: var(--surface-2); position: relative; cursor: pointer; padding: 0; @@ -2696,13 +2703,14 @@ body::after { position: absolute; inset-block-start: 2px; inset-inline-start: 2px; - inline-size: 18px; - block-size: 18px; + inline-size: 19px; + block-size: 19px; border-radius: 50%; - background: var(--surface); + background: #fff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); transition: inset-inline-start 0.18s cubic-bezier(0.4, 0, 0.2, 1); } -.src-switch.is-on .src-switch__knob { inset-inline-start: 18px; } +.src-switch.is-on .src-switch__knob { inset-inline-start: 21px; } @media (prefers-reduced-motion: reduce) { .src-switch, .src-switch__knob { transition: none; } } @@ -2719,6 +2727,15 @@ body::after { } .src-remove { flex: 0 0 auto; border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: var(--text-md); } .src-remove:hover { color: var(--warn); } +/* §7 per-chat mute: quiet by default, accent-filled when suggestions are muted. */ +.src-mute { + flex: 0 0 auto; display: grid; place-items: center; + inline-size: 30px; block-size: 30px; border-radius: var(--radius-sm); + border: 1px solid var(--line); background: var(--surface); color: var(--muted); cursor: pointer; +} +.src-mute:hover { background: var(--hover); color: var(--ink-2); } +.src-mute.is-on { background: var(--accent-weak); border-color: transparent; color: var(--accent-ink); } +.src-mute .ic { inline-size: 15px; block-size: 15px; } .src-section--removed .src-row--removed { justify-content: space-between; } .src-restore { font: inherit; font-size: var(--text-sm); border: 0; background: transparent; color: var(--accent-ink); cursor: pointer; } @@ -2756,13 +2773,21 @@ body::after { } .beta { font-size: var(--text-xs); - font-weight: 600; - color: var(--accent-ink); - background: var(--accent-weak); - border: 1px solid var(--line); + font-weight: 700; + letter-spacing: 0.02em; + color: var(--warn-ink); + background: var(--warn-weak); border-radius: var(--radius-pill); padding: 2px 8px; } +/* delete-everything confirm dialog (§8) */ +.confirm-card { text-align: center; padding: 22px 20px; max-inline-size: 340px; margin: 0 auto; } +.confirm-ic { inline-size: 48px; block-size: 48px; border-radius: 14px; background: var(--warn-weak); color: var(--warn-ink); display: grid; place-items: center; margin: 0 auto 12px; } +.confirm-card b { font-size: var(--text-md); font-weight: 800; } +.confirm-card p { margin: 6px 0 16px; font-size: var(--text-sm); color: var(--ink-2); line-height: 1.5; } +.confirm-row { display: flex; gap: 10px; } +.confirm-row .btn { flex: 1; justify-content: center; } +.btn-danger { background: var(--warn); color: #fff; } .set-card { display: flex; @@ -3371,12 +3396,11 @@ button.srcchip:hover { filter: brightness(0.97); } .ppl-row { display: flex; align-items: flex-start; - gap: 12px; + gap: 14px; text-align: start; - padding: 12px 14px; - border: 1px solid var(--line); + padding: 16px; + border: 1px solid transparent; border-radius: var(--radius-lg); - background: var(--surface); cursor: pointer; font-family: inherit; color: var(--ink); @@ -3391,15 +3415,15 @@ button.srcchip:hover { filter: brightness(0.97); } gap: 8px; flex-wrap: wrap; font-weight: 700; - font-size: var(--text-base); + font-size: 15.5px; 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-row__note { margin: 3px 0 0; font-size: 13.5px; color: var(--ink-2); line-height: 1.5; } +.ppl-row__meta { margin-top: 8px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: 12px; 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__name { font-size: 18px; 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; } @@ -3408,15 +3432,11 @@ button.srcchip:hover { filter: brightness(0.97); } .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; + font-size: 11.5px; + font-weight: 600; + letter-spacing: 0.03em; + color: var(--muted); } .ppl-next { display: flex; @@ -3704,7 +3724,7 @@ svg.ic{ width:20px; height:20px; stroke:currentColor; stroke-width:1.9; stroke-l .chip.on{ background:var(--ink); color:var(--bg); border-color:transparent; } .chip .ic{ width:14px; height:14px; } -.src{ display:inline-flex; align-items:center; gap:6px; font:500 11.5px "Heebo",sans-serif; color:var(--accent-ink); background:var(--accent-weak); padding:4px 9px; border-radius:8px; cursor:pointer; } +.src{ display:inline-flex; align-items:center; gap:6px; font:500 11.5px "Heebo",sans-serif; color:var(--accent-ink); background:var(--accent-weak); padding:4px 9px; border-radius:8px; cursor:pointer; white-space:nowrap; } .src-date{ opacity:.72; } .src .ic{ width:13px; height:13px; } .src:hover{ filter:brightness(.97); } @@ -4316,8 +4336,8 @@ h2.sec .ic{ width:15px; height:15px; } .btn-danger{ background:var(--warn); color:#fff; } /* ---- v2 command center (desktop Today) ---- */ -.cc{ display:grid; grid-template-columns:minmax(0,1fr) 340px; gap:26px; align-items:start; padding:8px 4px; } -.cc-hero{ display:flex; justify-content:center; } +.cc{ display:grid; grid-template-columns:minmax(0,1fr) 300px; gap:24px; align-items:start; padding:8px 4px 24px; } +.cc-hero{ min-width:0; } .cc-hero .today{ padding-top:6px; } .cc-side{ display:flex; flex-direction:column; gap:14px; position:sticky; top:8px; } .cc-panel{ overflow:hidden; } @@ -4346,6 +4366,80 @@ h2.sec .ic{ width:15px; height:15px; } @media (max-width:780px){ .cc{ grid-template-columns:1fr; } .cc-side{ position:static; } + .dgc-foot{ gap:8px; } + .dgc-actions{ width:100%; margin-inline-start:0; } + .dgc-actions .btn-primary{ margin-inline-start:auto; } +} + +/* ---- desktop digest board (v2 Today, web-native) ---- */ +.dg-board{ position:relative; width:100%; } +.dg-head{ margin-bottom:20px; } +.dg-greet{ font-size:30px; font-weight:800; letter-spacing:-.025em; line-height:1.05; } +.dg-subline{ display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-top:7px; font:600 13.5px "Heebo",sans-serif; color:var(--muted); } +.dg-date{ color:var(--ink-2); } +.dg-bull{ opacity:.5; } +.dg-note{ display:inline-flex; align-items:center; gap:7px; margin-top:14px; padding:8px 14px; font-size:12.5px; font-weight:600; color:var(--accent-ink); background:var(--accent-weak); border-radius:999px; } +.dg-note .ic{ color:var(--accent-ink); flex:none; } +.dg-grid{ display:flex; flex-direction:column; gap:12px; } +.dg-loading{ border-radius:18px; min-height:300px; display:flex; align-items:center; justify-content:center; } +.dg-card{ display:flex; flex-direction:column; gap:13px; padding:18px 20px; border-radius:18px; } +.dg-card.s-suggest{ border-color:color-mix(in srgb, var(--accent) 42%, var(--line)); } +.dgc-top{ display:flex; align-items:flex-start; gap:13px; } +.dgc-top > .sg-spark, .dgc-top > .avatar{ margin-top:1px; } +.dgc-head{ flex:1; min-width:0; } +.dgc-head .kicker{ font-size:11.5px; } +.dgc-title{ margin:3px 0 0; font-size:18px; font-weight:800; letter-spacing:-.02em; line-height:1.25; text-wrap:pretty; } +.dgc-top .sg-tag, .dgc-top .badge{ flex:none; align-self:center; } +.dgc-body{ font-size:14.5px; color:var(--ink-2); line-height:1.55; } +.dg-card .sg-draft{ margin:0; } +.dg-card .sg-recap{ margin:0; } +.dgc-reason{ display:flex; gap:8px; align-items:flex-start; font-size:13px; color:var(--ink-2); line-height:1.45; } +.dgc-reason .ic{ color:var(--accent-ink); flex:none; margin-top:2px; } +.dgc-foot{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; padding-top:13px; border-top:1px solid var(--line); } +.dgc-actions{ display:flex; align-items:center; gap:7px; margin-inline-start:auto; } +@keyframes dgOut{ to{ opacity:0; transform:scale(.96) translateY(-4px); } } +.dg-card.leaving{ animation:dgOut .24s cubic-bezier(.4,0,.2,1) forwards; pointer-events:none; } +@media (prefers-reduced-motion:reduce){ .dg-card.leaving{ animation:none; opacity:0; } } +.dg-flash{ position:absolute; left:50%; bottom:24px; transform:translateX(-50%) translateY(8px); z-index:60; background:var(--ink); color:var(--bg); font-weight:700; font-size:14px; padding:10px 20px; border-radius:999px; opacity:0; pointer-events:none; transition:opacity .2s, transform .2s; box-shadow:0 8px 24px -8px rgba(0,0,0,.45); } +.dg-flash.show{ opacity:1; transform:translateX(-50%) translateY(0); } +.dg-board .done-state, .dg-board .done-state.surface{ margin:0 auto; } + +/* ---- playful "summarizing" loader (.sumload) ---- */ +.sumload{ display:flex; flex-direction:column; align-items:center; text-align:center; padding:40px 24px 36px; } +.sumload--compact{ padding:28px 18px 24px; } +.sumload-scene{ position:relative; width:150px; height:118px; display:grid; place-items:center; margin-bottom:16px; } +.sumload-core{ position:relative; z-index:3; width:62px; height:62px; border-radius:19px; display:grid; place-items:center; background:var(--surface); box-shadow:0 12px 28px -12px rgba(20,24,28,.4); animation:slBob 2.4s ease-in-out infinite; } +@keyframes slBob{ 0%,100%{ transform:translateY(0); } 50%{ transform:translateY(-5px); } } +.sumload-ring{ position:absolute; z-index:2; width:90px; height:90px; border-radius:50%; box-shadow:0 0 0 0 var(--accent-weak); animation:slPulse 2s ease-out infinite; } +@keyframes slPulse{ 0%{ box-shadow:0 0 0 0 var(--accent-weak); } 70%,100%{ box-shadow:0 0 0 16px transparent; } } +.sumload-orbit{ position:absolute; z-index:1; width:106px; height:106px; animation:slSpin 4.5s linear infinite; } +.sumload-orbit i{ position:absolute; width:8px; height:8px; border-radius:50%; background:var(--accent); opacity:.6; } +.sumload-orbit i:nth-child(1){ top:0; inset-inline-start:50%; margin-inline-start:-4px; } +.sumload-orbit i:nth-child(2){ bottom:6px; inset-inline-start:6px; width:6px; height:6px; } +.sumload-orbit i:nth-child(3){ bottom:12px; inset-inline-end:4px; width:5px; height:5px; opacity:.4; } +@keyframes slSpin{ to{ transform:rotate(360deg); } } +.sumload-floats{ position:absolute; inset:0; z-index:0; pointer-events:none; } +.sumload-floats i{ position:absolute; bottom:6px; width:17px; height:15px; border-radius:42% 42% 42% 8%; background:var(--accent-weak); animation:slRise 2.6s ease-in infinite; opacity:0; } +.sumload-floats i:nth-child(1){ inset-inline-start:32px; animation-delay:0s; } +.sumload-floats i:nth-child(2){ inset-inline-start:67px; animation-delay:.8s; } +.sumload-floats i:nth-child(3){ inset-inline-start:101px; animation-delay:1.5s; } +@keyframes slRise{ 0%{ transform:translateY(0) scale(.7); opacity:0; } 22%{ opacity:.85; } 70%{ opacity:.85; } 100%{ transform:translateY(-72px) scale(.3); opacity:0; } } +.sumload-spark{ position:absolute; z-index:4; color:var(--accent); animation:slTwinkle 1.8s ease-in-out infinite; } +.sumload-spark.s1{ top:8px; inset-inline-end:30px; } +.sumload-spark.s2{ bottom:18px; inset-inline-start:22px; animation-delay:.6s; } +.sumload-spark.s3{ top:26px; inset-inline-start:36px; color:var(--warn); animation-delay:1.1s; } +@keyframes slTwinkle{ 0%,100%{ opacity:0; transform:scale(.4); } 50%{ opacity:.9; transform:scale(1); } } +.sumload-title{ font-size:16.5px; font-weight:800; letter-spacing:-.01em; } +.sumload-quip{ font-size:13.5px; font-weight:600; color:var(--accent-ink); min-height:19px; margin-top:5px; animation:slFade .5s ease; } +@keyframes slFade{ from{ opacity:0; transform:translateY(3px); } to{ opacity:1; transform:none; } } +.sumload-bar{ width:190px; max-width:72%; height:6px; border-radius:6px; background:var(--surface-2); overflow:hidden; margin-top:18px; position:relative; } +.sumload-bar b{ position:absolute; top:0; bottom:0; inset-inline-start:0; width:42%; border-radius:6px; background:var(--accent); animation:slSlide 1.25s ease-in-out infinite; } +@keyframes slSlide{ 0%{ transform:translateX(-130%); } 100%{ transform:translateX(300%); } } +.sum-card--loading{ display:flex; align-items:center; justify-content:center; min-height:300px; } +@media (prefers-reduced-motion:reduce){ + .sumload-core,.sumload-orbit,.sumload-ring,.sumload-floats i,.sumload-spark,.sumload-bar b,.sumload-quip{ animation:none; } + .sumload-floats i{ opacity:0; } + .sumload-bar b{ width:100%; } } /* ---- "add a chat you missed" nudge (Today) ---- */ diff --git a/src/workers/handlers/suggest-generate.test.ts b/src/workers/handlers/suggest-generate.test.ts index a4c8f53..a2168ff 100644 --- a/src/workers/handlers/suggest-generate.test.ts +++ b/src/workers/handlers/suggest-generate.test.ts @@ -20,7 +20,7 @@ function makeDeps(over: Partial = {}): SuggestGenerateDeps return { pool: {} as never, loadPerChat: vi.fn().mockResolvedValue(perChat), - loadIncludedGroupIds: vi.fn().mockResolvedValue([1, 2]), + loadSuggestibleGroupIds: vi.fn().mockResolvedValue([1, 2]), loadEngineConfigRaw: vi.fn().mockResolvedValue({ on: true, proact: "מאוזן" }), loadBias: vi.fn().mockResolvedValue(new Map()), extract: vi.fn().mockResolvedValue([]), @@ -50,7 +50,7 @@ describe("suggestGenerateHandler", () => { ), ); const deps = makeDeps({ - loadIncludedGroupIds: vi.fn().mockResolvedValue([1]), // group 2 excluded + loadSuggestibleGroupIds: vi.fn().mockResolvedValue([1]), // group 2 excluded (or muted) loadEngineConfigRaw: vi.fn().mockResolvedValue({ on: true, kinds: { task: true, meeting: false, followup: false, recap: false }, diff --git a/src/workers/handlers/suggest-generate.ts b/src/workers/handlers/suggest-generate.ts index 0e1645d..aaa8653 100644 --- a/src/workers/handlers/suggest-generate.ts +++ b/src/workers/handlers/suggest-generate.ts @@ -24,8 +24,8 @@ export type SuggestGenerateDeps = { pool: pg.Pool; /** Read the total summary's per-chat entries by id. */ loadPerChat: (pool: pg.Pool, totalSummaryId: number) => Promise; - /** Included group ids (S4 scope filter). */ - loadIncludedGroupIds: (pool: pg.Pool) => Promise; + /** Suggestible group ids — included AND not muted (S4 scope filter + §7 mute). */ + loadSuggestibleGroupIds: (pool: pg.Pool) => Promise; /** The opaque engine_config blob from the S5 prefs store. */ loadEngineConfigRaw: (pool: pg.Pool) => Promise; /** Per-(kind,chat) feedback bias. */ @@ -53,8 +53,8 @@ export function makeSuggestGenerateHandler(deps: SuggestGenerateDeps) { if (!config.on) return; // master switch off → clean no-op const perChat = await deps.loadPerChat(deps.pool, totalSummaryId); - const included = new Set(await deps.loadIncludedGroupIds(deps.pool)); - const inScope = filterInScope(perChat, included); + const suggestible = new Set(await deps.loadSuggestibleGroupIds(deps.pool)); + const inScope = filterInScope(perChat, suggestible); if (inScope.length === 0) return; const bias = await deps.loadBias(deps.pool); diff --git a/src/workers/worker.ts b/src/workers/worker.ts index 33b690d..c73171b 100644 --- a/src/workers/worker.ts +++ b/src/workers/worker.ts @@ -433,7 +433,7 @@ async function main(): Promise { if (requestedTypes.includes("suggest.generate")) { const { makeSuggestGenerateHandler } = await import("./handlers/suggest-generate.js"); const { getTotalSummaryById } = await import("../db/repositories/total-summaries.js"); - const { listIncludedGroupIds } = await import("../db/repositories/chat-scopes.js"); + const { listSuggestibleGroupIds } = await import("../db/repositories/chat-scopes.js"); const { getPreferences } = await import("../db/repositories/user-preferences.js"); const { insertSuggestions, loadBias } = await import("../db/repositories/suggestions.js"); const { makeOllamaExtractor } = await import("../summarization/suggest-extractor.js"); @@ -449,7 +449,7 @@ async function main(): Promise { handlers["suggest.generate"] = makeSuggestGenerateHandler({ pool, loadPerChat: async (p, id) => (await getTotalSummaryById(p, id))?.output.perChat ?? [], - loadIncludedGroupIds: (p) => listIncludedGroupIds(p), + loadSuggestibleGroupIds: (p) => listSuggestibleGroupIds(p), loadEngineConfigRaw: async (p) => (await getPreferences(p))?.engineConfig ?? {}, loadBias: (p) => loadBias(p), extract: makeOllamaExtractor(suggestSummarizer),