Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions artifacts/tc-reference/src/components/library-today.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion artifacts/tc-reference/src/lib/cards/TC001.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
32 changes: 16 additions & 16 deletions artifacts/tc-reference/src/lib/cards/TC029.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand All @@ -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.",
],
},
{
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion artifacts/tc-reference/src/lib/cards/TC058.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
10 changes: 5 additions & 5 deletions artifacts/tc-reference/src/lib/cards/TC066.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
],
},
{
Expand Down Expand Up @@ -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.",
],
},
{
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion artifacts/tc-reference/src/lib/cards/TC086.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?"',
},
{
Expand Down
25 changes: 25 additions & 0 deletions artifacts/tc-reference/src/lib/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -779,3 +779,28 @@ export const LIBRARY_CATEGORIES: Record<string, LibraryCard[]> = {
},
],
};

/**
* "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<string> = new Set(CORE_CARD_IDS);
27 changes: 26 additions & 1 deletion artifacts/tc-reference/src/lib/drill-state.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { LIBRARY_CATEGORIES } from "./data";
import { CORE_CARD_IDS, LIBRARY_CATEGORIES } from "./data";
import {
isFiniteNumber,
isIntegerInRange,
Expand All @@ -14,13 +14,27 @@ 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;
streak: number;
lastCompletionDate: string | null;
prevCardIndex: number;
prevDayIndex: number;
order?: DrillOrder;
}

export const DRILL_STORAGE_KEY = "tc_drill_state";
Expand All @@ -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<DrillState, "order">, 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");
Expand Down Expand Up @@ -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 = <K extends keyof DrillState>(
key: K,
Expand All @@ -85,6 +108,7 @@ export function sanitiseDrillState(value: unknown): DrillState {
prevDayIndex: pick("prevDayIndex", (v) =>
isIntegerInRange(v, -1, LAST_DAY_INDEX),
),
...(order ? { order } : {}),
};
}

Expand Down Expand Up @@ -135,6 +159,7 @@ export function completeDrill(state: DrillState): DrillState {
lastCompletionDate: today,
prevCardIndex: state.cardIndex,
prevDayIndex: state.dayIndex,
...(state.order ? { order: state.order } : {}),
};
}

Expand Down
5 changes: 3 additions & 2 deletions artifacts/tc-reference/src/pages/drill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { CardData } from "@/lib/card-types";
import { LIBRARY_CATEGORIES } from "@/lib/data";
import {
CARD_IDS,
drillCardId,
DrillState,
loadDrillState,
saveDrillState,
Expand Down Expand Up @@ -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
Expand Down
25 changes: 22 additions & 3 deletions artifacts/tc-reference/src/pages/library.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1084,6 +1090,19 @@ export default function Library() {
style={{ color: "var(--fg-90)" }}
>
{card.title}
{CORE_CARD_SET.has(card.id) && (
<span
className="ml-1.5 inline-block align-middle rounded-full px-1.5 py-px text-[10.5px] font-semibold leading-4"
style={{
background:
"color-mix(in srgb, var(--brand) 14%, transparent)",
color: "var(--brand-text)",
}}
data-testid={`core-badge-${card.id}`}
>
Start here
</span>
)}
</span>
<span
className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-[12px]"
Expand Down
13 changes: 12 additions & 1 deletion artifacts/tc-reference/src/test/content-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
inferPhraseTone,
isSpeakablePhrase,
} from "@/lib/cards";
import { LIBRARY_CATEGORIES } from "@/lib/data";
import { CORE_CARD_IDS, LIBRARY_CATEGORIES } from "@/lib/data";
import { loadAllAggregatedPhrases } from "@/lib/phrases-data";

const PUBLIC_DIR = path.resolve(
Expand Down Expand Up @@ -228,3 +228,14 @@ describe("card content invariants", () => {
}
});
});

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"]),
);
});
});
Loading
Loading