feat(slack): ask the other person too, and put rating before sharing
ci / lint-test (push) Successful in 43s

Two halves of the same requirement, one of which was quietly missing.

Sharing split the money but never reached her: she is not in #smarthome,
so the card whose caption said "both verdicts welcome" was one she could
not see. Now a share DMs her a card of her own. A DM rather than adding
her to the channel, so her surface stays "orders that concern me"
instead of the whole house's ops feed. She was already in SLACK_USER_MAP,
so her press files under participant 4.

Only on the press that turns sharing ON, and only when someone else did
the sharing. Re-notifying on every later rating press would turn one
shared meal into a stream of DMs, which is how a nudge gets muted.

Her card carries no share button: she is being told it was shared, not
asked to decide, and two people toggling one split from separate copies
of a card is a race with no upside.

The app still holds no Slack bot token — it returns a notify instruction
and n8n sends it, the same shape as the modal open. If SLACK_USER_MAP has
no id for her the DM is skipped silently: the split is correct and
complete either way, and failing the press over an unaddressable nudge
would be the worse trade.

Card reordered to rate -> details -> share. You judge the food, then
decide who pays for it; asking "was this shared?" first inverts the
order a person thinks in. The status caption moved under the share button
it describes rather than sitting orphaned mid-card.
This commit is contained in:
2026-07-28 18:33:25 +10:00
parent 0595d49d5c
commit 9f0f38449b
3 changed files with 145 additions and 32 deletions
+93 -30
View File
@@ -50,25 +50,6 @@ export function nudgeBlocks(s: NudgeState) {
return [
{ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } },
{
type: "actions",
block_id: "share",
elements: [
{
type: "button",
action_id: "share_toggle",
// The label states what pressing it will DO, not the current state.
// A button labelled with its own state reads as already-pressed and
// gets tapped to "fix" it, toggling the thing it was reporting.
text: {
type: "plain_text",
text: s.shared ? "Make it just me" : "Shared 50/50",
},
style: s.shared ? undefined : "primary",
value: `${s.transactionId}:share`,
},
],
},
// A select, not four buttons. Slack's mobile client gives every button in
// an actions block its own full-width row, so four ratings became four
// stacked bars and the card filled the screen. One select is one row and
@@ -89,17 +70,6 @@ export function nudgeBlocks(s: NudgeState) {
},
],
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: s.shared
? ":busts_in_silhouette: Split 50/50 — both verdicts welcome"
: ":bust_in_silhouette: Not shared",
},
],
},
{
type: "actions",
block_id: "details",
@@ -112,6 +82,40 @@ export function nudgeBlocks(s: NudgeState) {
},
],
},
// Sharing comes LAST. You judge the food, then decide who pays for it —
// asking "was this shared?" before "was it any good?" inverts the order a
// person actually thinks in (user, 2026-07-28). The controls are
// independent and each writes immediately, so this is presentation only.
{
type: "actions",
block_id: "share",
elements: [
{
type: "button",
action_id: "share_toggle",
// The label states what pressing it will DO, not the current state.
// A button labelled with its own state reads as already-pressed and
// gets tapped to "fix" it, toggling the thing it was reporting.
text: {
type: "plain_text",
text: s.shared ? "Make it just me" : "Shared 50/50",
},
style: s.shared ? undefined : "primary",
value: `${s.transactionId}:share`,
},
],
},
{
type: "context",
elements: [
{
type: "mrkdwn",
text: s.shared
? ":busts_in_silhouette: Split 50/50 — both verdicts welcome"
: ":bust_in_silhouette: Not shared",
},
],
},
];
}
@@ -220,3 +224,62 @@ export function detailsModal(
],
};
}
/**
* The card DMed to the other person when an order is shared with them.
*
* Sharing splits the money; this is the other half of the same requirement —
* "if shared then get feedback from other user as well" (user, 2026-07-28).
* Without it that half was aspirational: she is not in #smarthome, so the
* channel card she was invited to answer was one she could not see.
*
* A DM rather than adding her to the channel, so her surface stays "orders that
* concern me" instead of the whole house's ops feed.
*
* No share button here on purpose. She is being told it was shared, not asked
* to decide — and a second person toggling the split from a stale copy of the
* card is a race with no upside.
*/
export function partnerNudgeBlocks(s: NudgeState, sharerName: string) {
const lines = [
`:receipt: *${s.merchant}* — ${s.currency} ${s.total.toFixed(2)}`,
`${sharerName} shared this with you, 50/50. How was it?`,
];
if (s.warn) {
lines.push(
`:warning: This merchant was marked *never again* before` +
(s.warnNote ? ` — _${s.warnNote}_` : "")
);
}
return [
{ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } },
{
type: "actions",
block_id: "rate",
elements: [
{
type: "static_select",
action_id: "rate",
placeholder: { type: "plain_text", text: "How was it?" },
options: RATINGS.map((r) => ({
text: { type: "plain_text", text: RATING_LABEL[r] },
value: `${s.transactionId}:rate:${r}`,
})),
},
],
},
{
type: "actions",
block_id: "details",
elements: [
{
type: "button",
action_id: "open_details",
text: { type: "plain_text", text: "Add details" },
value: `${s.transactionId}:details`,
},
],
},
];
}
+10
View File
@@ -47,6 +47,16 @@ export function verifySlackSignature(
* two-person household a wrong attribution is not a rounding error, it is the
* other person's opinion recorded under your name.
*/
/** The reverse: which Slack user is this participant, for DMing them. */
export function slackUserForParticipant(participantId: number): string | null {
const map = process.env.SLACK_USER_MAP ?? "";
for (const pair of map.split(",")) {
const [slack, participant] = pair.split(":").map((s) => s.trim());
if (slack && Number(participant) === participantId) return slack;
}
return null;
}
export function participantForSlackUser(slackUserId: string): number | null {
const map = process.env.SLACK_USER_MAP ?? "";
for (const pair of map.split(",")) {