@ketch.to/schema 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,120 @@
1
+ export const doCard = {
2
+ title: "Reply to Shaina's marketing plan",
3
+ body: "You said 'more detailed reply to come' on Tue. The plan is in the thread; she asked about Q1 spend and the podcast idea.",
4
+ source: { app: "Claude", context: "Dosi-Smash Marketing Plan" },
5
+ link: "https://mail.google.com/mail/u/0/#inbox/FMfcgzQbfxQ",
6
+ priority: 2,
7
+ kind: "do",
8
+ meta: { thread_id: "FMfcgzQbfxQ", rule: "promised-reply" },
9
+ };
10
+ export const chooseCard = {
11
+ title: "Keep the Tuesday standup?",
12
+ body: "Attendance has been 3/7 for the last four weeks. Bryan asked twice whether it's still needed.",
13
+ source: { app: "Claude", context: "Calendar hygiene" },
14
+ priority: 4,
15
+ kind: "choose",
16
+ meta: { event_id: "standup-tue" },
17
+ };
18
+ export const checkinCard = {
19
+ title: "Still waiting on the Fly.io invoice from accounting?",
20
+ body: "Open since Oct 12. Last touch: you forwarded it to Priya on Oct 28.",
21
+ source: { app: "Claude", context: "Open loops" },
22
+ priority: 3,
23
+ kind: "checkin",
24
+ meta: { loop_id: "fly-invoice" },
25
+ };
26
+ export const decideCard = {
27
+ title: "Supabase or Vapor for the Ketch backend?",
28
+ body: "CLAUDE.md says Supabase by default. You've gone back and forth twice in Granola. Pick one and write it down.",
29
+ source: { app: "Claude", context: "Ketch architecture" },
30
+ link: "https://notes.granola.ai/d/4f1c2a",
31
+ priority: 2,
32
+ kind: "decide",
33
+ meta: { decision: "backend-stack" },
34
+ };
35
+ export const readCard = {
36
+ title: "Granola recap: Dosi-Smash weekly",
37
+ body: "- Shaina wants a reply on the marketing plan by Friday\n- Bryan owns the podcast outreach now\n- Next sync moved to Thursdays",
38
+ source: { app: "Claude", context: "Dosi-Smash weekly" },
39
+ link: "https://notes.granola.ai/d/9b2e77",
40
+ priority: 4,
41
+ kind: "read",
42
+ expires_at: "2026-11-12T07:00:00-08:00",
43
+ };
44
+ export const recapCard = {
45
+ title: "Anything else from today's Dosi-Smash call?",
46
+ body: "I captured: reply to Shaina, podcast outreach → Bryan. Add what I missed.",
47
+ source: { app: "Claude", context: "Dosi-Smash weekly" },
48
+ priority: 3,
49
+ kind: "recap",
50
+ };
51
+ /** Explicit actions override the preset; snooze_for sets the tap default. */
52
+ export const customActionsCard = {
53
+ title: "Approve the $1,200 Zapier annual plan?",
54
+ body: "Renews Nov 9. Last year you used 14% of the task quota.",
55
+ source: { app: "Zapier", context: "Billing" },
56
+ link: "https://zapier.com/app/billing",
57
+ priority: 1,
58
+ kind: "decide",
59
+ actions: [
60
+ { id: "approve", label: "Approve", style: "positive" },
61
+ { id: "downgrade", label: "Downgrade", style: "neutral", input: "optional" },
62
+ { id: "later", label: "Ask me Monday", style: "snooze", snooze_for: "4d" },
63
+ ],
64
+ meta: { renewal_id: "zap-2026" },
65
+ };
66
+ /** Minimal: title only. Kind, actions, priority all come from defaults. */
67
+ export const minimalCard = { title: "Call the dentist" };
68
+ /** Email-in shape: subject = title, body = body, link = message. */
69
+ export const emailInCard = {
70
+ title: "Fwd: Lease renewal paperwork",
71
+ body: "Hi Ev, attached is the renewal for 2027. Needs a signature by the 20th.",
72
+ source: { app: "Email", context: "landlord@example.com" },
73
+ link: "https://mail.google.com/mail/u/0/#inbox/18f3a2c",
74
+ kind: "do",
75
+ };
76
+ /** Hidden until the morning; a producer-side snooze. */
77
+ export const scheduledCard = {
78
+ title: "Morning: review yesterday's not_good outcomes",
79
+ source: { app: "Claude", context: "Self-tuning" },
80
+ priority: 5,
81
+ kind: "read",
82
+ show_after: "2026-11-06T07:00:00-08:00",
83
+ recurrence: { every: "1d" },
84
+ };
85
+ export const byKind = {
86
+ do: doCard,
87
+ choose: chooseCard,
88
+ checkin: checkinCard,
89
+ decide: decideCard,
90
+ read: readCard,
91
+ recap: recapCard,
92
+ };
93
+ export const validCards = {
94
+ ...byKind,
95
+ customActions: customActionsCard,
96
+ minimal: minimalCard,
97
+ emailIn: emailInCard,
98
+ scheduled: scheduledCard,
99
+ };
100
+ /** Each must fail validateNewCard; `path` is the expected first error path. */
101
+ export const invalidCards = {
102
+ missingTitle: { card: { body: "no title" }, path: "" },
103
+ emptyTitle: { card: { title: "" }, path: "/title" },
104
+ unknownField: { card: { title: "x", due: "tomorrow" }, path: "" },
105
+ unknownKind: { card: { title: "x", kind: "task" }, path: "/kind" },
106
+ priorityOutOfRange: { card: { title: "x", priority: 0 }, path: "/priority" },
107
+ httpLink: { card: { title: "x", link: "http://example.com" }, path: "/link" },
108
+ tooManyActions: {
109
+ card: { title: "x", actions: [{ id: "a", label: "A" }, { id: "b", label: "B" }, { id: "c", label: "C" }, { id: "d", label: "D" }, { id: "e", label: "E" }] },
110
+ path: "/actions",
111
+ },
112
+ noActions: { card: { title: "x", actions: [] }, path: "/actions" },
113
+ duplicateActionIds: { card: { title: "x", actions: [{ id: "a", label: "A" }, { id: "a", label: "Again" }] }, path: "/actions/1/id" },
114
+ badActionId: { card: { title: "x", actions: [{ id: "Done!", label: "Done" }] }, path: "/actions/0/id" },
115
+ badStyle: { card: { title: "x", actions: [{ id: "a", label: "A", style: "danger" }] }, path: "/actions/0/style" },
116
+ snoozeForOnNonSnooze: { card: { title: "x", actions: [{ id: "a", label: "A", style: "neutral", snooze_for: "1d" }] }, path: "/actions/0/snooze_for" },
117
+ badRecurrence: { card: { title: "x", recurrence: { every: "fortnight" } }, path: "/recurrence/every" },
118
+ badShowAfter: { card: { title: "x", show_after: "tomorrow 7am" }, path: "/show_after" },
119
+ metaNotObject: { card: { title: "x", meta: "notes" }, path: "/meta" },
120
+ };
@@ -0,0 +1,7 @@
1
+ import type { Duration } from "./types.js";
2
+ export declare const DURATION_RE: RegExp;
3
+ export declare function isDuration(s: unknown): s is Duration;
4
+ /** "14d" → milliseconds. Throws on a malformed duration. */
5
+ export declare function durationToMs(d: Duration | string): number;
6
+ /** now + duration, as an ISO string. */
7
+ export declare function addDuration(from: Date, d: Duration | string): string;
@@ -0,0 +1,21 @@
1
+ const UNIT_MS = {
2
+ m: 60_000,
3
+ h: 3_600_000,
4
+ d: 86_400_000,
5
+ w: 7 * 86_400_000,
6
+ };
7
+ export const DURATION_RE = /^([1-9][0-9]{0,4})([mhdw])$/;
8
+ export function isDuration(s) {
9
+ return typeof s === "string" && DURATION_RE.test(s);
10
+ }
11
+ /** "14d" → milliseconds. Throws on a malformed duration. */
12
+ export function durationToMs(d) {
13
+ const m = DURATION_RE.exec(d);
14
+ if (!m)
15
+ throw new Error(`Invalid duration: ${d}`);
16
+ return Number(m[1]) * UNIT_MS[m[2]];
17
+ }
18
+ /** now + duration, as an ISO string. */
19
+ export function addDuration(from, d) {
20
+ return new Date(from.getTime() + durationToMs(d)).toISOString();
21
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./presets.js";
3
+ export * from "./duration.js";
4
+ export * from "./validate.js";
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./presets.js";
3
+ export * from "./duration.js";
4
+ export * from "./validate.js";
@@ -0,0 +1,22 @@
1
+ import type { Action, Input, Kind, NewCard, Recurrence } from "./types.js";
2
+ /**
3
+ * Kind presets. A kind only fills in defaults the producer omitted; it never
4
+ * needs app code. Adding a kind = adding an entry here.
5
+ */
6
+ export interface KindPreset {
7
+ actions: Action[];
8
+ recurrence?: Recurrence;
9
+ input?: Input;
10
+ }
11
+ export declare const KIND_PRESETS: Record<Kind, KindPreset>;
12
+ export declare const DEFAULT_KIND: Kind;
13
+ export declare const DEFAULT_PRIORITY: 3;
14
+ /**
15
+ * Fill in everything the preset provides that the producer left out.
16
+ * Pure; returns a new object. Does not validate.
17
+ */
18
+ export declare function withKindDefaults<T extends NewCard>(card: T): T & {
19
+ kind: Kind;
20
+ actions: Action[];
21
+ priority: number;
22
+ };
@@ -0,0 +1,56 @@
1
+ export const KIND_PRESETS = {
2
+ do: {
3
+ actions: [
4
+ { id: "done", label: "Done", style: "positive" },
5
+ { id: "snooze", label: "Snooze", style: "snooze" },
6
+ { id: "feedback", label: "Feedback", style: "neutral", input: "optional" },
7
+ ],
8
+ },
9
+ choose: {
10
+ actions: [
11
+ { id: "keep", label: "Keep", style: "positive" },
12
+ { id: "next", label: "Next", style: "neutral" },
13
+ ],
14
+ },
15
+ checkin: {
16
+ actions: [
17
+ { id: "still_true", label: "Still true", style: "positive" },
18
+ { id: "better", label: "Better", style: "neutral", input: "optional" },
19
+ { id: "resolved", label: "Resolved", style: "neutral" },
20
+ ],
21
+ recurrence: { every: "14d" },
22
+ },
23
+ decide: {
24
+ actions: [
25
+ { id: "decided", label: "Decided", style: "positive", input: "required" },
26
+ { id: "still_deciding", label: "Still deciding", style: "snooze", snooze_for: "3d" },
27
+ ],
28
+ },
29
+ read: {
30
+ actions: [{ id: "next", label: "Next", style: "positive" }],
31
+ },
32
+ recap: {
33
+ actions: [
34
+ { id: "nothing_more", label: "Nothing more", style: "positive" },
35
+ { id: "snooze", label: "Snooze", style: "snooze" },
36
+ ],
37
+ input: { placeholder: "Any other follow-ups?", spawns: true },
38
+ },
39
+ };
40
+ export const DEFAULT_KIND = "do";
41
+ export const DEFAULT_PRIORITY = 3;
42
+ /**
43
+ * Fill in everything the preset provides that the producer left out.
44
+ * Pure; returns a new object. Does not validate.
45
+ */
46
+ export function withKindDefaults(card) {
47
+ const kind = card.kind ?? DEFAULT_KIND;
48
+ const preset = KIND_PRESETS[kind];
49
+ const out = { ...card, kind, priority: card.priority ?? DEFAULT_PRIORITY };
50
+ out.actions = (card.actions ?? preset.actions).map((a) => ({ style: "neutral", ...a }));
51
+ if (card.recurrence === undefined && preset.recurrence)
52
+ out.recurrence = { ...preset.recurrence };
53
+ if (card.input === undefined && preset.input)
54
+ out.input = { ...preset.input };
55
+ return out;
56
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * TypeScript mirror of card.schema.json. Keep the two in sync; the test
3
+ * `schema.test.ts` validates every fixture against the JSON Schema and the
4
+ * fixtures are typed with these types, so drift shows up at compile or test time.
5
+ */
6
+ /** `<integer><unit>`; unit m | h | d | w. e.g. "1h", "3d", "14d". */
7
+ export type Duration = `${number}${"m" | "h" | "d" | "w"}`;
8
+ export type CardId = `card_${string}`;
9
+ export type WebhookId = `wh_${string}`;
10
+ export type ActionStyle = "positive" | "snooze" | "neutral";
11
+ export type ActionInput = "optional" | "required";
12
+ export interface Action {
13
+ /** Producer-chosen. `snooze` and `not_good` are reserved. */
14
+ id: string;
15
+ label: string;
16
+ /** default "neutral" */
17
+ style?: ActionStyle;
18
+ /** Open a text sheet before posting. */
19
+ input?: ActionInput;
20
+ /** Only meaningful for style "snooze": default snooze on tap (default 1h). */
21
+ snooze_for?: Duration;
22
+ }
23
+ export interface Source {
24
+ /** Defaults to the API key's name. */
25
+ app?: string;
26
+ context?: string;
27
+ }
28
+ export interface Input {
29
+ placeholder?: string;
30
+ /** Each entry becomes a new card (card.spawned). */
31
+ spawns?: boolean;
32
+ }
33
+ export interface Recurrence {
34
+ every: Duration;
35
+ }
36
+ export type Kind = "do" | "choose" | "checkin" | "decide" | "read" | "recap";
37
+ export declare const KINDS: readonly Kind[];
38
+ export type Priority = 1 | 2 | 3 | 4 | 5;
39
+ export type Status = "open" | "snoozed" | "done";
40
+ export type WebhookEvent = "card.acted" | "card.snoozed" | "card.spawned";
41
+ export declare const WEBHOOK_EVENTS: readonly WebhookEvent[];
42
+ /** Reserved action ids the app always offers in the overflow menu. */
43
+ export declare const RESERVED_ACTION_IDS: readonly ["snooze", "not_good"];
44
+ export type ReservedActionId = (typeof RESERVED_ACTION_IDS)[number];
45
+ /** What a producer POSTs to /cards. */
46
+ export interface NewCard {
47
+ title: string;
48
+ body?: string;
49
+ source?: Source;
50
+ link?: string;
51
+ priority?: Priority;
52
+ kind?: Kind;
53
+ actions?: Action[];
54
+ input?: Input;
55
+ recurrence?: Recurrence;
56
+ show_after?: string | null;
57
+ expires_at?: string | null;
58
+ meta?: Record<string, unknown>;
59
+ }
60
+ /** What PATCH /cards/:id accepts. */
61
+ export interface CardPatch {
62
+ title?: string;
63
+ body?: string | null;
64
+ actions?: Action[];
65
+ show_after?: string | null;
66
+ priority?: Priority;
67
+ link?: string | null;
68
+ expires_at?: string | null;
69
+ meta?: Record<string, unknown>;
70
+ }
71
+ export interface Outcome {
72
+ action: string;
73
+ text: string | null;
74
+ at: string;
75
+ snoozed_until: string | null;
76
+ }
77
+ /** What the app POSTs to /cards/:id/outcome. */
78
+ export interface OutcomeInput {
79
+ action: string;
80
+ text?: string | null;
81
+ snoozed_until?: string | null;
82
+ /** Titles of cards to spawn from the card's spawning input. */
83
+ spawn?: string[];
84
+ }
85
+ /** A card as the server returns it. */
86
+ export interface Card {
87
+ id: CardId;
88
+ title: string;
89
+ body?: string;
90
+ source: {
91
+ app: string;
92
+ context?: string;
93
+ };
94
+ link?: string;
95
+ priority: Priority;
96
+ kind: Kind;
97
+ actions: Action[];
98
+ input?: Input;
99
+ recurrence?: Recurrence;
100
+ show_after?: string | null;
101
+ expires_at?: string | null;
102
+ meta?: Record<string, unknown>;
103
+ status: Status;
104
+ parent_id?: CardId | null;
105
+ outcome?: Outcome | null;
106
+ created_at: string;
107
+ updated_at: string;
108
+ retired_at?: string | null;
109
+ }
110
+ export interface NewWebhook {
111
+ url: string;
112
+ secret: string;
113
+ events: WebhookEvent[];
114
+ }
115
+ export interface Webhook {
116
+ id: WebhookId;
117
+ url: string;
118
+ events: WebhookEvent[];
119
+ created_at: string;
120
+ }
121
+ export type WebhookPayload = {
122
+ id: string;
123
+ event: "card.acted";
124
+ at: string;
125
+ card: Card;
126
+ outcome: Outcome;
127
+ } | {
128
+ id: string;
129
+ event: "card.snoozed";
130
+ at: string;
131
+ card: Card;
132
+ outcome: Outcome;
133
+ } | {
134
+ id: string;
135
+ event: "card.spawned";
136
+ at: string;
137
+ parent: Card;
138
+ card: Card;
139
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * TypeScript mirror of card.schema.json. Keep the two in sync; the test
3
+ * `schema.test.ts` validates every fixture against the JSON Schema and the
4
+ * fixtures are typed with these types, so drift shows up at compile or test time.
5
+ */
6
+ export const KINDS = ["do", "choose", "checkin", "decide", "read", "recap"];
7
+ export const WEBHOOK_EVENTS = ["card.acted", "card.snoozed", "card.spawned"];
8
+ /** Reserved action ids the app always offers in the overflow menu. */
9
+ export const RESERVED_ACTION_IDS = ["snooze", "not_good"];