File size: 13,177 Bytes
7d68132
 
 
b6afdb8
7d68132
401c4a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ceaaad
 
 
 
 
401c4a8
 
 
9ceaaad
401c4a8
 
 
 
 
7d68132
 
9ceaaad
 
7d68132
 
401c4a8
 
 
 
 
 
 
 
 
 
7d68132
 
 
 
 
 
 
 
 
 
 
401c4a8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d68132
 
 
 
401c4a8
9ceaaad
 
401c4a8
7d68132
401c4a8
 
 
 
 
 
9ceaaad
401c4a8
 
 
 
 
 
 
 
 
7d68132
401c4a8
7d68132
9ceaaad
 
 
7d68132
31abd7f
9ceaaad
31abd7f
 
9ceaaad
 
 
 
 
 
 
7d68132
 
 
31abd7f
b6afdb8
31abd7f
 
b6afdb8
 
 
 
31abd7f
 
 
 
 
 
 
9ceaaad
31abd7f
9ceaaad
7d68132
 
31abd7f
 
 
7d68132
 
31abd7f
 
 
 
 
 
 
9ceaaad
 
 
7d68132
9ceaaad
401c4a8
 
 
 
9ceaaad
401c4a8
9ceaaad
 
 
7d68132
 
9ceaaad
 
 
 
0338292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31abd7f
9ceaaad
 
 
 
 
31abd7f
7d68132
 
 
 
9ceaaad
 
31abd7f
7d68132
 
9ceaaad
7d68132
 
 
 
9ceaaad
7d68132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0338292
7d68132
 
 
0338292
7d68132
 
0338292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7d68132
 
 
 
 
9ceaaad
 
 
 
 
7d68132
 
 
 
 
401c4a8
 
 
7d68132
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9ceaaad
 
 
31abd7f
7d68132
9ceaaad
 
 
 
 
 
 
 
7d68132
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
import { useState, useEffect } from "react"
import api from "../lib/api"
import Reveal, { RevealItem } from "./Reveal"
import { signalColor } from "../lib/signalColor"

const G = "linear-gradient(135deg, #1d4ed8 0%, #0891b2 100%)"

// Matches ProfileView.jsx's SectionLabel exactly, for the same "eyebrow above
// a subheading" pattern used across You/History β€” Home didn't have one yet.
function SectionLabel({ children }) {
  return (
    <div style={{
      fontSize: 10, fontWeight: 700, letterSpacing: 1.4,
      textTransform: "uppercase", marginBottom: 4,
      background: G, WebkitBackgroundClip: "text",
      WebkitTextFillColor: "transparent", backgroundClip: "text",
    }}>
      {children}
    </div>
  )
}

// Visual identity per trigger type β€” independent of the (not-yet-built) per-
// dimension color palette on the You page; this is scoped to what Home's
// dimension-event cards need: a quick visual read on WHAT KIND of thing
// happened (a first discovery vs. a shift vs. something to just note), not
// WHICH of the 15 dimensions it is.
// icon is a fixed glyph per trigger type β€” it marks WHAT KIND of event this
// is and never changes. Direction (below) is a separate, dynamic ↑/↓ suffix
// only added when the backend has a real measured direction for this event.
const TRIGGER_CONFIG = {
  first_time_steady: { color: "#f59e0b", label: "New pattern", icon: "●" },
  context_shift:      { color: "#22d3ee", label: "Context shift", icon: "↕" },
  drift:              { color: "#34d399", label: "Shifted", icon: "β†—" },
  recurring:          { color: "#a78bfa", label: "Recurring", icon: "↻" },
  anomaly:            { color: "#fb7185", label: "Worth noting", icon: "✦" },
}

function recurringLabel(direction) {
  return direction === "back_to_usual" ? "Back to usual" : "New pattern"
}

// The ONLY values that ever mean a real up/down movement are the literal
// strings "up"/"down" β€” every other trigger type's `direction` field means
// something else entirely (recurring's is a state name, categorical
// anomaly's is "contradicts_established"), so this can't misfire on them.
function directionArrow(direction) {
  if (direction === "up") return "↑"
  if (direction === "down") return "↓"
  return ""
}

function DismissButton({ onDismiss }) {
  return (
    <button onClick={onDismiss} aria-label="Dismiss this card"
      style={{ background: "none", border: "none", cursor: "pointer",
        color: "#4a4865", fontSize: 16, lineHeight: 1, padding: "2px 4px",
        marginLeft: 8, flexShrink: 0 }}>
      Γ—
    </button>
  )
}

// Dims a hex color's RGB channels β€” used so the icon GLYPH itself sits a
// notch below the badge pill's full-saturation color, rather than competing
// with it at the same brightness.
function darken(hex, amount) {
  const num = parseInt(hex.slice(1), 16)
  const r = Math.round(((num >> 16) & 0xff) * (1 - amount))
  const g = Math.round(((num >> 8) & 0xff) * (1 - amount))
  const b = Math.round((num & 0xff) * (1 - amount))
  return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`
}

function IconBadge({ icon, color }) {
  return (
    <div style={{
      width: 30, height: 30, borderRadius: 8, flexShrink: 0,
      background: `${color}18`, border: `1px solid ${color}40`,
      display: "flex", alignItems: "center", justifyContent: "center",
      fontSize: 12.6, color: darken(color, 0.15), marginTop: 1,
    }}>
      {icon}
    </div>
  )
}

// `icon` is optional β€” only DimensionEventCard passes one. SessionRecapCard
// never does, so it keeps its current left-border-only look, untouched.
function CardShell({ color, icon, title, badge, faded, onDismiss, children }) {
  return (
    <div style={{
      padding: "14px 16px", borderRadius: 10,
      background: "#151922", border: "1px solid #1e2438",
      borderLeft: icon ? "1px solid #1e2438" : `3px solid ${color}`,
      opacity: faded ? 0.45 : 1,
      transition: "opacity 0.3s ease",
      display: "flex", gap: 12,
    }}>
      {icon && <IconBadge icon={icon} color={color} />}
      <div style={{ flex: 1, minWidth: 0 }}>
        <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", marginBottom: 6 }}>
          <div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
            <span style={{ fontSize: 13, fontWeight: 700, color: "#f0eeff", textTransform: "capitalize" }}>
              {title}
            </span>
            {badge && (
              <span style={{ fontSize: 11, color, fontWeight: 600,
                background: `${color}15`, border: `1px solid ${color}30`,
                borderRadius: 20, padding: "3px 10px", whiteSpace: "nowrap" }}>
                {badge}
              </span>
            )}
          </div>
          <DismissButton onDismiss={onDismiss} />
        </div>
        {children}
      </div>
    </div>
  )
}

function SessionRecapCard({ card, faded, onDismiss, onOpenSession }) {
  const dateLabel = card.date ? new Date(card.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }) : ""
  const obs = card.observation

  return (
    <CardShell color="#818cf8" title="Session recap"
      badge={[card.context?.replace(/_/g, " "), dateLabel].filter(Boolean).join(" Β· ")}
      faded={faded} onDismiss={onDismiss}>
      {card.conversation_summary && (
        <p style={{ margin: "0 0 10px", fontSize: 13, color: "#c4c2d8", lineHeight: 1.65 }}>
          {card.conversation_summary}
        </p>
      )}

      {/* Exactly one observation, backend-ranked β€” the other two + their tips
          (and the resonance vote) live on the full session detail page */}
      {obs && (
        <div style={{ marginBottom: card.tip ? 10 : 6 }}>
          <div style={{ fontSize: 10.5, fontWeight: 600, color: signalColor(obs.signal),
            textTransform: "uppercase", letterSpacing: 0.5, marginBottom: 5 }}>
            {obs.signal.replace(/_/g, " ")}
          </div>
          <p style={{ margin: 0, fontSize: 13, color: "#c4c2d8", lineHeight: 1.6 }}>
            {obs.observation}
          </p>

          {obs.resonance_prompt && (
            <p style={{ margin: "8px 0 0", fontSize: 12, color: "#6b6888", fontStyle: "italic", lineHeight: 1.5 }}>
              πŸ’­ {obs.resonance_prompt}
            </p>
          )}
        </div>
      )}

      {card.tip && (
        <p style={{ margin: "0 0 10px", fontSize: 12, color: "#8b89aa", lineHeight: 1.55 }}>
          πŸ’‘ {card.tip}
        </p>
      )}

      <button onClick={() => onOpenSession(card.session_id)}
        style={{ background: "none", border: "none", cursor: "pointer",
          color: "#5b9cf6", fontSize: 12, fontWeight: 500, padding: 0,
          marginTop: 2 }}>
        View full session β†’
      </button>
    </CardShell>
  )
}

function DimensionEventCard({ card, faded, onDismiss }) {
  const trig = TRIGGER_CONFIG[card.trigger_type] || { color: "#8b89aa", label: card.trigger_type, icon: "●" }
  const baseLabel = card.trigger_type === "recurring" ? recurringLabel(card.direction) : trig.label
  const arrow = card.trigger_type === "recurring" ? "" : directionArrow(card.direction)
  const badge = arrow ? `${baseLabel} ${arrow}` : baseLabel
  return (
    <CardShell color={trig.color} icon={trig.icon} title={card.label} badge={badge} faded={faded} onDismiss={onDismiss}>
      {card.note && (
        <p style={{ margin: 0, fontSize: 13, color: "#c4c2d8", lineHeight: 1.65 }}>
          {card.note}
        </p>
      )}
    </CardShell>
  )
}

// A real DimensionEventCard rendered with fabricated content, shown only in
// the empty state so a brand-new user sees what their feed becomes instead
// of just reading about it. `onDismiss` is a no-op β€” there's no real card
// behind this to persist a dismissal for.
function ExampleCard() {
  const sampleCard = {
    label: "Curiosity",
    trigger_type: "first_time_steady",
    direction: null,
    note: "You asked more follow-up questions than usual across your last two calls β€” steady enough now to call it a pattern.",
  }
  return (
    <div style={{ position: "relative" }}>
      <span style={{
        position: "absolute", top: -9, left: 14, zIndex: 1,
        fontSize: 9.5, fontWeight: 700, letterSpacing: 1,
        textTransform: "uppercase", color: "#0d0f16",
        background: "#8b89aa", borderRadius: 20, padding: "2px 8px",
      }}>
        Example
      </span>
      <DimensionEventCard card={sampleCard} faded={false} onDismiss={() => {}} />
    </div>
  )
}

function HomeCard({ card, i, faded, onDismiss, onOpenSession }) {
  const dismiss = () => onDismiss(card.card_key)
  return (
    <RevealItem index={i}>
      {card.type === "dimension_event"
        ? <DimensionEventCard card={card} faded={faded} onDismiss={dismiss} />
        : <SessionRecapCard card={card} faded={faded} onDismiss={dismiss} onOpenSession={onOpenSession} />}
    </RevealItem>
  )
}

const VISIBLE_COUNT = 7

export default function HomeView({ active, onOpenSession }) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)
  const [expanded, setExpanded] = useState(false)

  useEffect(() => {
    if (!active) return
    setLoading(true)
    setExpanded(false)
    api.get("/api/home")
      .then(res => setData(res.data))
      .catch(console.error)
      .finally(() => setLoading(false))
  }, [active])

  const handleDismiss = (cardKey) => {
    // Optimistic β€” remove immediately, persist in the background, silent on
    // failure (same pattern as ResultsView's resonance voting: a dismiss is a
    // low-stakes preference, not something worth blocking or retrying on).
    setData(d => d ? { ...d, cards: d.cards.filter(c => c.card_key !== cardKey) } : d)
    const form = new FormData()
    form.append("card_key", cardKey)
    api.post("/api/home/dismiss", form).catch(() => {})
  }

  if (loading) return (
    <div style={{ textAlign: "center", padding: 60, color: "#4a4865" }}>
      Loading your mirror…
    </div>
  )

  if (!data || data.insufficient_data) {
    return (
      <div style={{ textAlign: "center", padding: "60px 20px 20px" }}>
        <h2 style={{ fontSize: 20, fontWeight: 700, margin: "0 0 10px", color: "#f0eeff" }}>
          Your mirror is waiting
        </h2>
        <p style={{ fontSize: 14, color: "#8b89aa", margin: "0 0 24px", lineHeight: 1.6 }}>
          Record your first Google Meet call to start building your feed.
        </p>
        <a href="https://meet.google.com/" target="_blank" rel="noreferrer"
          style={{ display: "inline-block", padding: "12px 28px", background: G,
            color: "white", border: "none", borderRadius: 8, fontSize: 14,
            fontWeight: 600, textDecoration: "none",
            boxShadow: "0 0 24px rgba(29,78,216,0.25)" }}>
          Open Google Meet β†’
        </a>

        <p style={{ fontSize: 11, fontWeight: 700, letterSpacing: 1.2,
          textTransform: "uppercase", color: "#4a4865", margin: "48px 0 12px" }}>
          What a card looks like
        </p>
        <div style={{ textAlign: "left", maxWidth: 420, margin: "0 auto" }}>
          <ExampleCard />
        </div>
      </div>
    )
  }

  const cards = data.cards || []
  // Backend returns the complete, newest-first, dismissed-filtered list β€”
  // pagination is purely a frontend rendering concern: 7 visible + an 8th
  // shown faded as a teaser, "Show more" reveals the rest uncapped.
  const hasOverflow = cards.length > VISIBLE_COUNT
  const visibleCards = expanded ? cards : cards.slice(0, VISIBLE_COUNT + 1)

  return (
    <div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
      <Reveal>
      <div>
        <SectionLabel>Your Feed</SectionLabel>
        <h2 style={{ fontSize: 14, fontWeight: 700, margin: "0 0 4px", color: "#f0eeff" }}>
          Mirror Feed
        </h2>
        <span style={{ fontSize: 12, color: "#4a4865" }}>
          {data.session_count} session{data.session_count > 1 ? "s" : ""}
        </span>
      </div>
      </Reveal>

      {cards.length === 0 ? (
        <Reveal delay={80}>
        <p style={{ fontSize: 13, color: "#4a4d6a", margin: 0, lineHeight: 1.7 }}>
          Nothing new to reflect back yet β€” keep recording, and your feed will start
          filling in as patterns become clear.
        </p>
        </Reveal>
      ) : (
        <Reveal delay={80}>
        <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
          {visibleCards.map((card, i) => (
            <HomeCard key={card.card_key} card={card} i={i}
              faded={!expanded && hasOverflow && i === VISIBLE_COUNT}
              onDismiss={handleDismiss} onOpenSession={onOpenSession} />
          ))}
          {!expanded && hasOverflow && (
            <button onClick={() => setExpanded(true)}
              style={{ alignSelf: "center", marginTop: 4, background: "none",
                border: "none", cursor: "pointer", color: "#7c8fd6",
                fontSize: 13, fontWeight: 600, padding: "8px 0" }}>
              Show more
            </button>
          )}
        </div>
        </Reveal>
      )}
    </div>
  )
}