diff --git a/artifacts/tc-reference/src/components/library-today.tsx b/artifacts/tc-reference/src/components/library-today.tsx index 2848f40..7fec7ae 100644 --- a/artifacts/tc-reference/src/components/library-today.tsx +++ b/artifacts/tc-reference/src/components/library-today.tsx @@ -3,7 +3,7 @@ import { Link } from "wouter"; import { Dumbbell, History, Zap, ChevronRight } from "lucide-react"; import { LIBRARY_CATEGORIES } from "@/lib/data"; import { - CARD_IDS, + drillCardId, getDueCardsCount, isCompletedToday, isStreakActive, @@ -68,7 +68,7 @@ export function LibraryToday() { const drill = loadDrillState(); const done = isCompletedToday(drill); const streak = isStreakActive(drill) ? drill.streak : 0; - const nextCard = CARD_IDS[drill.cardIndex] ?? CARD_IDS[0]; + const nextCard = drillCardId(drill, drill.cardIndex); return { done, streak, diff --git a/artifacts/tc-reference/src/lib/cards/TC001.ts b/artifacts/tc-reference/src/lib/cards/TC001.ts index 462c6c9..bdf134e 100644 --- a/artifacts/tc-reference/src/lib/cards/TC001.ts +++ b/artifacts/tc-reference/src/lib/cards/TC001.ts @@ -56,7 +56,7 @@ export const TC001: CardData = { ], id: "TC001", whyItWorks: - "A live-thread follow-up is a question or comment that follows the most emotionally alive, meaningful, surprising, funny, tense, proud, uncertain, or specific part of what someone just said. It works because people open up and warm to you when they feel you noticed the real part of what they said, not just the surface facts. In Huang and colleagues 2017 studies, people who asked more questions, especially follow-up questions, were better liked, because they came across as more responsive.", + "A live-thread follow-up is a question or comment that follows the most emotionally alive, meaningful, surprising, funny, tense, proud, uncertain, or specific part of what someone just said. It works because people open up and warm to you when they feel you noticed the real part of what they said, not just the surface facts. In Huang and colleagues 2017 studies, people who asked more questions, especially follow-up questions, were better liked, because they came across as more responsive. A 2019 reanalysis by Kluger and Malloy questioned how much of that liking comes from the asker alone, so treat it as a useful nudge, not a guarantee.", whatItIsNot: [ 'It is not simply "ask more questions": too many questions can feel like an interview.', "It is not therapy-speak or clinical probing.", diff --git a/artifacts/tc-reference/src/lib/cards/TC029.ts b/artifacts/tc-reference/src/lib/cards/TC029.ts index c148d15..799ec9d 100644 --- a/artifacts/tc-reference/src/lib/cards/TC029.ts +++ b/artifacts/tc-reference/src/lib/cards/TC029.ts @@ -131,34 +131,34 @@ export const TC029: CardData = { tag: "Ask, then hold the pause", tone: "Direct", phrases: [ - "What was the real issue there? [pause]", - "What do you make of that? [pause]", - "What would make this workable? [pause]", - "What are you worried I'm not seeing? [pause]", - "What does your instinct say? [pause]", + "What was the real issue there?", + "What do you make of that?", + "What would make this workable?", + "What are you worried I'm not seeing?", + "What does your instinct say?", ], }, { id: "professional", label: "Professional", - tag: "Work, meetings, recommendations", + tag: "Work, meetings: say it, then pause", tone: "Professional", phrases: [ "I'll pause there because that's the key trade-off.", "I don't want to over-talk the recommendation.", "I'll stop there and hear your concern.", - "Bottom line: I recommend X. [pause]", - "What's your reaction? [pause]", + "Bottom line: I recommend X.", + "What's your reaction?", "Let me put the option on the table and then hear you.", ], }, { id: "conflict-tension", label: "Conflict / tension", - tag: "Heat, pressure, disagreement", + tag: "Heat, pressure: pause after the first line", tone: "High-stakes", phrases: [ - "I hear the concern. [pause]", + "I hear the concern.", "I don't want to respond too quickly and miss the point.", "Take a second. What feels most unresolved?", "I'm not ignoring you. I'm trying not to react too fast.", @@ -169,14 +169,14 @@ export const TC029: CardData = { { id: "high-status-busy", label: "High-status / busy", - tag: "Concise, invites challenge", + tag: "Concise: ask, then pause", tone: "Direct", phrases: [ "I'll keep it brief and pause there.", - "Your read? [pause]", - "What would you challenge? [pause]", + "Your read?", + "What would you challenge?", "I'll stop there rather than over-explain.", - "Happy to be challenged. [pause]", + "Happy to be challenged.", ], }, { @@ -279,12 +279,12 @@ export const TC029: CardData = { { situation: "Negotiation", move: "Ask a calibrated question and let it sit.", - phrase: "How could we make that work with the timeline? [pause]", + phrase: "How could we make that work with the timeline?", }, { situation: "High-status or busy person", move: "Be concise and do not over-justify.", - phrase: "Bottom line: I'd choose X. Happy to be challenged. [pause]", + phrase: "Bottom line: I'd choose X. Happy to be challenged.", }, { situation: "Shy or guarded person", diff --git a/artifacts/tc-reference/src/lib/cards/TC058.ts b/artifacts/tc-reference/src/lib/cards/TC058.ts index 43fdbc6..77bd1a4 100644 --- a/artifacts/tc-reference/src/lib/cards/TC058.ts +++ b/artifacts/tc-reference/src/lib/cards/TC058.ts @@ -166,7 +166,7 @@ export const TC058: CardData = { "It sounds like you felt hurt because you needed your side to be taken seriously.", "Maybe the anger is partly about needing a fairer process.", "It sounds like the issue was needing a real apology, not just an explanation.", - "I hear that you needed me to check before deciding.", + "Sounds like you needed me to check before deciding.", "You're right, it sounds like you needed acknowledgement before explanation.", "I can see why that landed badly. It sounds like you needed to be heard first.", "Sounds like what stung was being added after the fact, not the change itself.", diff --git a/artifacts/tc-reference/src/lib/cards/TC066.ts b/artifacts/tc-reference/src/lib/cards/TC066.ts index e96db40..2b5b620 100644 --- a/artifacts/tc-reference/src/lib/cards/TC066.ts +++ b/artifacts/tc-reference/src/lib/cards/TC066.ts @@ -76,7 +76,7 @@ export const TC066: CardData = { "Brief: one short paragraph. Informative: facts, not counter-claims. Friendly: civil language, no contempt. Firm: a clear end, boundary, or action.", "Thanks for the update. The report was sent at 4:15 pm yesterday. I'll resend it now, and use email for any further changes.", "I understand this is frustrating. The meeting is Thursday at 10 am. I'll discuss the agenda in the meeting, not by text.", - "I hear that you disagree. My decision remains the same. I'm available to discuss implementation steps, not personal accusations.", + "I understand you disagree. My decision remains the same. I'm available to discuss implementation steps, not personal accusations.", ], minimumViableMove: "Write three sentences: one civil opening, one factual answer, one firm next step.", @@ -117,7 +117,7 @@ export const TC066: CardData = { "I've read your message.", "I understand this is important to you.", "I can see this has been frustrating.", - "I hear that you disagree.", + "I know you see it differently.", ], }, { @@ -180,10 +180,10 @@ export const TC066: CardData = { tag: "De-escalating a personal thread", tone: "Repair", phrases: [ - "I hear that you're upset. I can help with the appointment on Friday at 10 am. I'm not available for blame by text.", + "I can tell you're upset. I can help with the appointment on Friday at 10 am. I'm not available for blame by text.", "Thanks for letting me know. I'll bring the forms at pickup. I'm not going to discuss blame by text.", "I understand this is stressful. I can take Dad to the Friday appointment at 10 am, and discuss anything broader when we're both calmer.", - "I hear that you're upset. The plan is 5 pm at Mum's. I'm not going to revisit insults from the text thread.", + "I know you're upset. The plan is 5 pm at Mum's. I'm not going to revisit insults from the text thread.", ], }, { @@ -472,7 +472,7 @@ export const TC066: CardData = { situation: "Family accusation", move: "Acknowledge the feeling briefly, offer the concrete help, and decline the blame.", phrase: - "I hear that you're upset. I can help with the appointment on Friday at 10 am. I'm not available for blame by text.", + "I can tell you're upset. I can help with the appointment on Friday at 10 am. I'm not available for blame by text.", }, { situation: "Repeated bait or a demand to apologise", diff --git a/artifacts/tc-reference/src/lib/cards/TC086.ts b/artifacts/tc-reference/src/lib/cards/TC086.ts index 6236c73..ff23d5d 100644 --- a/artifacts/tc-reference/src/lib/cards/TC086.ts +++ b/artifacts/tc-reference/src/lib/cards/TC086.ts @@ -239,7 +239,7 @@ export const TC086: CardData = { ladder: [ { weak: "\"You're wrong, but I'm listening.\"", - better: '"I hear that you don\'t see it that way. What am I missing?"', + better: '"Sounds like you don\'t see it that way. What am I missing?"', best: '"From your side, this feels like people are deciding for you. I can see why that would make you resist. I agree you should have a say. Can we find one step that keeps your choice intact and still handles the concern?"', }, { diff --git a/artifacts/tc-reference/src/lib/data.ts b/artifacts/tc-reference/src/lib/data.ts index 3bd80db..5730ede 100644 --- a/artifacts/tc-reference/src/lib/data.ts +++ b/artifacts/tc-reference/src/lib/data.ts @@ -779,3 +779,28 @@ export const LIBRARY_CATEGORIES: Record = { }, ], }; + +/** + * "Start here" core set: the 15 highest-yield everyday moves, in the order + * a new reader's drill visits them. Every id must exist above (checked in + * content-invariants). Order matters; it is the new-reader drill order. + */ +export const CORE_CARD_IDS: readonly string[] = [ + "TC004", + "TC006", + "TC011", + "TC009", + "TC016", + "TC082", + "TC013", + "TC052", + "TC095", + "TC066", + "TC005", + "TC002", + "TC101", + "TC102", + "TC103", +]; + +export const CORE_CARD_SET: ReadonlySet = new Set(CORE_CARD_IDS); diff --git a/artifacts/tc-reference/src/lib/drill-state.ts b/artifacts/tc-reference/src/lib/drill-state.ts index 9359abd..3acead6 100644 --- a/artifacts/tc-reference/src/lib/drill-state.ts +++ b/artifacts/tc-reference/src/lib/drill-state.ts @@ -1,4 +1,4 @@ -import { LIBRARY_CATEGORIES } from "./data"; +import { CORE_CARD_IDS, LIBRARY_CATEGORIES } from "./data"; import { isFiniteNumber, isIntegerInRange, @@ -14,6 +14,19 @@ export const CARD_IDS = Object.values(LIBRARY_CATEGORIES) .map((c) => c.id) .sort(); +/** + * Drill order for readers who start fresh: the "Start here" core set first, + * then every other card in id order. Same length as CARD_IDS. + */ +export const CORE_FIRST_CARD_IDS: readonly string[] = [ + ...CORE_CARD_IDS.filter((id) => CARD_IDS.includes(id)), + ...CARD_IDS.filter((id) => !CORE_CARD_IDS.includes(id)), +]; + +/** "core-first" for new drills; absent on drills saved before it existed, + which keep the id order so a saved cardIndex still means the same card. */ +export type DrillOrder = "core-first"; + export interface DrillState { cardIndex: number; dayIndex: number; @@ -21,6 +34,7 @@ export interface DrillState { lastCompletionDate: string | null; prevCardIndex: number; prevDayIndex: number; + order?: DrillOrder; } export const DRILL_STORAGE_KEY = "tc_drill_state"; @@ -33,8 +47,15 @@ const DEFAULT_STATE: DrillState = { lastCompletionDate: null, prevCardIndex: -1, prevDayIndex: -1, + order: "core-first", }; +/** The card id at a drill index, in the order this drill uses. */ +export function drillCardId(state: Pick, index: number) { + const ids = state.order === "core-first" ? CORE_FIRST_CARD_IDS : CARD_IDS; + return ids[index] ?? ids[0]; +} + function localISO(date: Date): string { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); @@ -62,6 +83,8 @@ const LAST_DAY_INDEX = 6; */ export function sanitiseDrillState(value: unknown): DrillState { if (!isRecord(value)) return DEFAULT_STATE; + const order: DrillOrder | undefined = + value.order === "core-first" ? "core-first" : undefined; const lastCard = CARD_IDS.length - 1; const pick = ( key: K, @@ -85,6 +108,7 @@ export function sanitiseDrillState(value: unknown): DrillState { prevDayIndex: pick("prevDayIndex", (v) => isIntegerInRange(v, -1, LAST_DAY_INDEX), ), + ...(order ? { order } : {}), }; } @@ -135,6 +159,7 @@ export function completeDrill(state: DrillState): DrillState { lastCompletionDate: today, prevCardIndex: state.cardIndex, prevDayIndex: state.dayIndex, + ...(state.order ? { order: state.order } : {}), }; } diff --git a/artifacts/tc-reference/src/pages/drill.tsx b/artifacts/tc-reference/src/pages/drill.tsx index c6f1fce..7f488e1 100644 --- a/artifacts/tc-reference/src/pages/drill.tsx +++ b/artifacts/tc-reference/src/pages/drill.tsx @@ -15,6 +15,7 @@ import type { CardData } from "@/lib/card-types"; import { LIBRARY_CATEGORIES } from "@/lib/data"; import { CARD_IDS, + drillCardId, DrillState, loadDrillState, saveDrillState, @@ -75,12 +76,12 @@ export default function Drill() { const activeDayIndex = done && state.prevDayIndex >= 0 ? state.prevDayIndex : state.dayIndex; - const cardId = CARD_IDS[activeCardIndex] ?? CARD_IDS[0]; + const cardId = drillCardId(state, activeCardIndex); const cardData = activeCard?.cardId === cardId ? activeCard.data : null; const drillEntry = cardData?.drill[activeDayIndex]; const cardTitle = CARD_TITLE_MAP[cardId] ?? cardId; - const nextCardId = CARD_IDS[state.cardIndex] ?? CARD_IDS[0]; + const nextCardId = drillCardId(state, state.cardIndex); const nextCardTitle = CARD_TITLE_MAP[nextCardId] ?? nextCardId; const nextDrillEntry = nextCard?.cardId === nextCardId diff --git a/artifacts/tc-reference/src/pages/library.tsx b/artifacts/tc-reference/src/pages/library.tsx index bdabf68..d907c77 100644 --- a/artifacts/tc-reference/src/pages/library.tsx +++ b/artifacts/tc-reference/src/pages/library.tsx @@ -23,7 +23,7 @@ import { Mic, Target, } from "lucide-react"; -import { LIBRARY_CATEGORIES, CardImpact } from "@/lib/data"; +import { CORE_CARD_SET, LIBRARY_CATEGORIES, CardImpact } from "@/lib/data"; import { useNav } from "@/lib/nav-context"; import { useFavourites } from "@/lib/favourites-context"; import { useScrollDirection } from "@/hooks/use-scroll-direction"; @@ -301,11 +301,12 @@ const DIFFICULTY_OPTIONS: DropdownOption[] = DIFFICULTY_LEVELS.map((diff) => ({ })); const SORT_OPTIONS: DropdownOption[] = [ + { value: "core", label: "Start here first", short: "Start here" }, { value: "impact", label: "Impact · high first", short: "Impact" }, { value: "difficulty", label: "Difficulty · easy first", short: "Easiest" }, ]; -type SortKey = "default" | "impact" | "difficulty"; +type SortKey = "default" | "core" | "impact" | "difficulty"; // ── Scroll restoration ── // Leaving the Library saves where the reader was (sessionStorage) against an @@ -656,7 +657,12 @@ export default function Library() { ); } - if (sortBy === "impact") { + if (sortBy === "core") { + filtered.sort( + (a, b) => + Number(CORE_CARD_SET.has(b.id)) - Number(CORE_CARD_SET.has(a.id)), + ); + } else if (sortBy === "impact") { filtered.sort((a, b) => IMPACT_ORDER[a.impact] - IMPACT_ORDER[b.impact]); } else if (sortBy === "difficulty") { filtered.sort( @@ -1084,6 +1090,19 @@ export default function Library() { style={{ color: "var(--fg-90)" }} > {card.title} + {CORE_CARD_SET.has(card.id) && ( + + Start here + + )} { } }); }); + +describe("start here core set", () => { + it("has 15 unique existing cards, including the three new everyday cards", () => { + expect(CORE_CARD_IDS).toHaveLength(15); + expect(new Set(CORE_CARD_IDS).size).toBe(15); + for (const id of CORE_CARD_IDS) expect(CARD_DATA).toHaveProperty(id); + expect(CORE_CARD_IDS).toEqual( + expect.arrayContaining(["TC101", "TC102", "TC103"]), + ); + }); +}); diff --git a/artifacts/tc-reference/src/test/drill-page.test.tsx b/artifacts/tc-reference/src/test/drill-page.test.tsx index 61b61b6..1c71fdc 100644 --- a/artifacts/tc-reference/src/test/drill-page.test.tsx +++ b/artifacts/tc-reference/src/test/drill-page.test.tsx @@ -10,7 +10,11 @@ import { useLocation } from "wouter"; import Drill from "../pages/drill"; import { loadCard } from "../lib/card-loader"; import { CARD_DATA } from "../lib/cards"; -import { CARD_IDS, loadDrillState, loadSRSMap } from "../lib/drill-state"; +import { + CORE_FIRST_CARD_IDS, + loadDrillState, + loadSRSMap, +} from "../lib/drill-state"; vi.mock("wouter"); vi.mock("../lib/card-loader", () => ({ @@ -41,7 +45,7 @@ async function renderDrill() { describe("Drill page (F-12)", () => { it("shows today's task for the first card", async () => { await renderDrill(); - const first = CARD_DATA[CARD_IDS[0]]; + const first = CARD_DATA[CORE_FIRST_CARD_IDS[0]]; expect(screen.getByText(first.drill[0].task)).toBeInTheDocument(); expect(screen.getByText("Start your streak")).toBeInTheDocument(); }); @@ -65,7 +69,7 @@ describe("Drill page (F-12)", () => { streak: 1, lastCompletionDate: "2026-03-10", }); - expect(loadSRSMap()[CARD_IDS[0]].interval).toBe(interval); + expect(loadSRSMap()[CORE_FIRST_CARD_IDS[0]].interval).toBe(interval); expect( screen.queryByRole("button", { name: "Good: review later" }), ).toBeNull(); @@ -89,7 +93,7 @@ describe("Drill page (F-12)", () => { it("opens the full card", async () => { await renderDrill(); fireEvent.click(screen.getByTestId("drill-view-card")); - expect(navigate).toHaveBeenCalledWith(`/card/${CARD_IDS[0]}`); + expect(navigate).toHaveBeenCalledWith(`/card/${CORE_FIRST_CARD_IDS[0]}`); }); it("shows the unavailable state when the card can't load", async () => { diff --git a/artifacts/tc-reference/src/test/drill-state.test.ts b/artifacts/tc-reference/src/test/drill-state.test.ts index a57a430..82148c4 100644 --- a/artifacts/tc-reference/src/test/drill-state.test.ts +++ b/artifacts/tc-reference/src/test/drill-state.test.ts @@ -3,6 +3,8 @@ import { applySM2, CARD_IDS, completeDrill, + CORE_FIRST_CARD_IDS, + drillCardId, DRILL_STATE_EVENT, getDueCardsCount, getTodayISO, @@ -27,8 +29,13 @@ const DEFAULT_DRILL: DrillState = { lastCompletionDate: null, prevCardIndex: -1, prevDayIndex: -1, + order: "core-first", }; +/** A drill saved before the core-first order existed (no order field). */ +const LEGACY_DRILL: DrillState = { ...DEFAULT_DRILL }; +delete LEGACY_DRILL.order; + function fresh(overrides: Partial = {}): CardSRS { return { cardId: "TC001", @@ -309,6 +316,7 @@ describe("daily streak (completeDrill)", () => { lastCompletionDate: "2026-03-10", prevCardIndex: 3, prevDayIndex: 2, + order: "core-first", }); expect(isCompletedToday(next)).toBe(true); expect(isStreakActive(next)).toBe(true); @@ -398,7 +406,7 @@ describe("loadDrillState / saveDrillState", () => { JSON.stringify({ cardIndex: 2, dayIndex: 1, streak: 3 }), ); expect(loadDrillState()).toEqual({ - ...DEFAULT_DRILL, + ...LEGACY_DRILL, cardIndex: 2, dayIndex: 1, streak: 3, @@ -426,7 +434,7 @@ describe("loadDrillState / saveDrillState", () => { }), ); expect(loadDrillState()).toEqual({ - ...DEFAULT_DRILL, + ...LEGACY_DRILL, streak: 5, prevDayIndex: 4, }); @@ -442,7 +450,7 @@ describe("loadDrillState / saveDrillState", () => { dayIndex: -1, }), ); - expect(loadDrillState()).toEqual(DEFAULT_DRILL); + expect(loadDrillState()).toEqual(LEGACY_DRILL); }); it("notifies listeners and survives a failing storage write", () => { @@ -456,3 +464,48 @@ describe("loadDrillState / saveDrillState", () => { window.removeEventListener(DRILL_STATE_EVENT, listener); }); }); + +describe("core-first drill order", () => { + beforeEach(() => localStorage.clear()); + + it("lists every card exactly once, core set first", () => { + expect([...CORE_FIRST_CARD_IDS].sort()).toEqual(CARD_IDS); + expect(CORE_FIRST_CARD_IDS.slice(0, 3)).toEqual([ + "TC004", + "TC006", + "TC011", + ]); + }); + + it("starts a new reader on the first core card", () => { + const state = loadDrillState(); + expect(state.order).toBe("core-first"); + expect(drillCardId(state, 0)).toBe("TC004"); + }); + + it("keeps a saved drill on the same card (no order field means id order)", () => { + localStorage.setItem( + DRILL_KEY, + JSON.stringify({ ...LEGACY_DRILL, cardIndex: 5 }), + ); + const state = loadDrillState(); + expect(state.order).toBeUndefined(); + expect(drillCardId(state, 5)).toBe(CARD_IDS[5]); + }); + + it("carries the order through a completed week", () => { + const next = completeDrill({ ...DEFAULT_DRILL, dayIndex: 6 }); + expect(next.order).toBe("core-first"); + expect(drillCardId(next, next.cardIndex)).toBe("TC006"); + const legacy = completeDrill({ ...LEGACY_DRILL, dayIndex: 6 }); + expect(legacy.order).toBeUndefined(); + }); + + it("ignores an unknown stored order", () => { + localStorage.setItem( + DRILL_KEY, + JSON.stringify({ ...LEGACY_DRILL, order: "random" }), + ); + expect(loadDrillState().order).toBeUndefined(); + }); +});