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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Each answer is posted as one card per person under a **per-date thread** in your
- **Vacation & skip** — DM `vacation`/`back` for yourself, a 🏖️ *Skip today* button on every prompt; away people aren't nagged or counted as missing. Optional **Google Calendar OOO sync** marks people away automatically.
- **Pre-fill** — "yesterday" starts as your previous "today".
- **Per-participant timezones** — prompts go out at 09:30 *their* time, reminder nudge before the deadline, late submissions flagged.
- **Blocker tracking & escalation** — blockers open automatically from answers, resolve on the next clean submission, surface via `blockers`/wrap-up/digest, and **DM a configured contact** when they stay open too long.
- **Blocker tracking, collaboration & escalation** — blockers open automatically from answers and can be **worked as items**: tag teammates (interactive DM card with Acknowledge / Update / Resolve), updates broadcast to everyone involved + a per-blocker thread, daily nudges until acknowledged, and escalation DMs when they go stale. Untagged blockers auto-resolve on the next clean submission; tagged ones need an explicit resolve.
- **Anonymous mood** (`mood anon`) — cards hide who felt what; the wrap-up shows the team average.
- **Web dashboard** — token-gated, server-rendered config + history UI baked into the same container (`DASHBOARD_TOKEN`).
- **Insights** — `trends` (participation + mood over 4 weeks), weekly digest (`digest on`), CSV export endpoint.
Expand Down
30 changes: 27 additions & 3 deletions docs/guide/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,32 @@ open to everyone in the space.
| --- | --- |
| `status` | Configuration + today's progress (submitted / pending / away) |
| `trends` | Last 4 weeks: participation % and average mood |
| `blockers` | Open blockers with their age |
| `blockers` | Open blockers with ids, age, who's tagged, and update counts |
| `export` | How to download a CSV of submissions |

## Working a blocker together (everyone)

Blockers get an id (`blockers` shows them). Tag teammates to pull them in:

| Command | Effect |
| --- | --- |
| `blocker 12 tag @Asha @Rohit` | Tagged people get an interactive DM card (✋ Acknowledge · 📝 Add update · ✅ Resolve) |
| `blocker 12 update <text>` | Post an update — DMed to the reporter and everyone tagged, and added to the blocker's thread in the space |
| `blocker 12 resolve` | Close it (reporter, tagged people, or admins) |

How collaboration changes the lifecycle:

- **Tagging makes it explicit-resolve.** Untagged blockers still auto-resolve
when the reporter's next standup is blocker-free; once a blocker is tagged
or has updates, only an explicit resolve closes it — someone's clean
standup can't silently end a conversation others are having.
- **Acknowledge stops the nudges.** Tagged people get one DM per day (at run
close) until they hit ✋ Acknowledge; posting an update acknowledges
implicitly. The reporter is notified of every ack.
- **Everything is tracked.** Updates and resolution land in a per-blocker
thread in the team space, and open blockers keep appearing in wrap-ups,
digests, and escalation pings until resolved.

## DM self-service

Anyone can DM the bot directly:
Expand All @@ -60,8 +83,9 @@ Anyone can DM the bot directly:
"today" answer.
- **Late submissions** after the deadline still post, flagged *late*; the
wrap-up isn't recalculated and late entries can no longer be edited.
- **Blockers** auto-resolve when the same person submits a blocker-free
standup on a later day. With `escalate @user` configured, the contact gets
- **Untagged blockers** auto-resolve when the same person submits a
blocker-free standup on a later day; tagged ones need an explicit
`blocker <id> resolve`. With `escalate @user` configured, the contact gets
**one** DM per blocker once it has been open past the threshold.
- **Calendar OOO** (when [enabled](./configuration)): participants with an
*Out of office* event in Google Calendar are automatically marked away for
Expand Down
10 changes: 8 additions & 2 deletions src/adapters/fake/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { ChatAdapter } from '../../core/adapter.js';
import type { Run, RunSummary, Standup, Submission } from '../../core/types.js';
import type { Blocker, Run, RunSummary, Standup, Submission } from '../../core/types.js';

interface SentDm {
kind: 'prompt' | 'reminder' | 'text';
kind: 'prompt' | 'reminder' | 'text' | 'blockerCard';
userName: string;
standupId?: number;
runId?: number;
blockerId?: number;
text?: string;
}

Expand Down Expand Up @@ -89,4 +90,9 @@ export class FakeAdapter implements ChatAdapter {
this.dms.push({ kind: 'text', userName, text });
this.log?.(`DM text → ${userName}: ${text.split('\n')[0]}`);
}

async sendBlockerCard(userName: string, standup: Standup, blocker: Blocker, note: string): Promise<void> {
this.dms.push({ kind: 'blockerCard', userName, standupId: standup.id, blockerId: blocker.id, text: note });
this.log?.(`DM blocker card #${blocker.id} → ${userName} (${note})`);
}
}
8 changes: 7 additions & 1 deletion src/adapters/gchat/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { auth as chatAuth, chat, type chat_v1 } from '@googleapis/chat';
import type { ChatAdapter } from '../../core/adapter.js';
import type { Repo } from '../../db/repo.js';
import type { Run, RunSummary, Standup, Submission } from '../../core/types.js';
import type { Blocker, Run, RunSummary, Standup, Submission } from '../../core/types.js';
import {
blockerCard,
promptMessage,
reminderMessage,
submissionMessage,
Expand Down Expand Up @@ -65,6 +66,11 @@ export class GoogleChatAdapter implements ChatAdapter {
await this.client.spaces.messages.create({ parent: dm, requestBody: { text } });
}

async sendBlockerCard(userName: string, standup: Standup, blocker: Blocker, note: string): Promise<void> {
const dm = await this.ensureDmSpace(userName);
await this.client.spaces.messages.create({ parent: dm, requestBody: blockerCard(standup, blocker, note) });
}

private async postInThread(
spaceName: string,
threadKey: string,
Expand Down
90 changes: 90 additions & 0 deletions src/adapters/gchat/cards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
MOOD_EMOJI,
MOOD_LABEL,
MOODS,
type Blocker,
type Run,
type RunSummary,
type Standup,
Expand All @@ -15,6 +16,10 @@ import {
export const OPEN_DIALOG_FN = 'openStandupDialog';
export const SUBMIT_DIALOG_FN = 'submitStandup';
export const SKIP_TODAY_FN = 'skipToday';
export const ACK_BLOCKER_FN = 'ackBlocker';
export const RESOLVE_BLOCKER_FN = 'resolveBlocker';
export const OPEN_BLOCKER_UPDATE_FN = 'openBlockerUpdate';
export const SUBMIT_BLOCKER_UPDATE_FN = 'submitBlockerUpdate';

function humanDate(isoDate: string): string {
return DateTime.fromISO(isoDate).toFormat('ccc, dd LLL yyyy');
Expand Down Expand Up @@ -154,6 +159,91 @@ export function standupDialog(
};
}

/** Interactive DM card for someone tagged on (or nudged about) a blocker. */
export function blockerCard(standup: Standup, blocker: Blocker, note: string) {
const param = [{ key: 'blockerId', value: String(blocker.id) }];
return {
cardsV2: [
{
cardId: `blocker-${blocker.id}`,
card: {
header: { title: `⚠️ Blocker #${blocker.id} — ${standup.name}`, subtitle: note },
sections: [
{
widgets: [
{
decoratedText: {
topLabel: `Reported by ${blocker.displayName} on ${blocker.openedDate}`,
text: blocker.text,
wrapText: true,
},
},
{
buttonList: {
buttons: [
{ text: '✋ Acknowledge', onClick: { action: { function: ACK_BLOCKER_FN, parameters: param } } },
{
text: '📝 Add update',
onClick: {
action: { function: OPEN_BLOCKER_UPDATE_FN, interaction: 'OPEN_DIALOG', parameters: param },
},
},
{ text: '✅ Resolve', onClick: { action: { function: RESOLVE_BLOCKER_FN, parameters: param } } },
],
},
},
],
},
],
},
},
],
};
}

/** Modal dialog for posting a blocker update. */
export function blockerUpdateDialog(blockerId: number) {
return {
actionResponse: {
type: 'DIALOG',
dialogAction: {
dialog: {
body: {
sections: [
{
widgets: [
{
textInput: {
name: 'update',
label: 'What is the latest on this blocker?',
type: 'MULTIPLE_LINE',
},
},
{
buttonList: {
buttons: [
{
text: 'Post update',
onClick: {
action: {
function: SUBMIT_BLOCKER_UPDATE_FN,
parameters: [{ key: 'blockerId', value: String(blockerId) }],
},
},
},
],
},
},
],
},
],
},
},
},
},
};
}

/** Plain text that opens the day's thread in the report space. */
export function threadParentText(standup: Standup, run: Run): string {
return `📅 *${standup.name}* — ${humanDate(run.date)}`;
Expand Down
55 changes: 54 additions & 1 deletion src/adapters/gchat/events.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { BlockerService } from '../../core/blocker-service.js';
import type { CommandHandler, Mention } from '../../core/commands.js';
import type { StandupService } from '../../core/standup-service.js';
import type { Repo } from '../../db/repo.js';
Expand All @@ -8,7 +9,17 @@ import {
type Answer,
type Mood,
} from '../../core/types.js';
import { OPEN_DIALOG_FN, SKIP_TODAY_FN, standupDialog, SUBMIT_DIALOG_FN } from './cards.js';
import {
ACK_BLOCKER_FN,
blockerUpdateDialog,
OPEN_BLOCKER_UPDATE_FN,
OPEN_DIALOG_FN,
RESOLVE_BLOCKER_FN,
SKIP_TODAY_FN,
standupDialog,
SUBMIT_BLOCKER_UPDATE_FN,
SUBMIT_DIALOG_FN,
} from './cards.js';

/**
* Routes Google Chat interaction events (MESSAGE, CARD_CLICKED, dialog
Expand All @@ -19,6 +30,7 @@ export class EventRouter {
constructor(
private commands: CommandHandler,
private service: StandupService,
private blockers: BlockerService,
private repo: Repo,
private tenantId: string,
) {}
Expand Down Expand Up @@ -121,6 +133,47 @@ export class EventRouter {
return { actionResponse: { type: 'UPDATE_MESSAGE' }, text: messages[result] };
}

const blockerId = Number(getParameter(event, 'blockerId'));

if (fn === ACK_BLOCKER_FN) {
const result = await this.blockers.acknowledge(blockerId, user);
const messages = {
acked: '✋ Acknowledged — the reporter has been told you are on it. Use the buttons above to post updates or resolve.',
already_acked: 'You already acknowledged this blocker.',
not_tagged: "You aren't tagged on this blocker.",
not_found: 'This blocker is already resolved or no longer exists.',
};
return { text: messages[result] };
}

if (fn === OPEN_BLOCKER_UPDATE_FN) {
if (!Number.isInteger(blockerId)) return dialogError('This blocker card is no longer valid.');
return blockerUpdateDialog(blockerId);
}

if (fn === SUBMIT_BLOCKER_UPDATE_FN) {
const text = getFormValue(event, 'update').trim();
if (!text) return dialogError('Please write the update first.');
const result = await this.blockers.addUpdate(blockerId, user, text);
const messages = {
ok: '📝 Update posted — everyone involved was notified.',
resolved: 'This blocker is already resolved.',
not_found: 'This blocker no longer exists.',
};
return result === 'ok' ? dialogOk(messages.ok) : dialogError(messages[result]);
}

if (fn === RESOLVE_BLOCKER_FN) {
const result = await this.blockers.resolve(blockerId, user);
const messages = {
resolved: '✅ Blocker resolved — everyone involved was notified.',
already_resolved: 'This blocker was already resolved.',
not_allowed: 'Only the reporter, tagged people, or a standup admin can resolve this blocker.',
not_found: 'This blocker no longer exists.',
};
return { text: messages[result] };
}

return {};
}

Expand Down
8 changes: 7 additions & 1 deletion src/core/adapter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Run, RunSummary, Standup, Submission } from './types.js';
import type { Blocker, Run, RunSummary, Standup, Submission } from './types.js';

/**
* Platform abstraction. The core never touches Google Chat (or Slack/Teams)
Expand Down Expand Up @@ -31,4 +31,10 @@ export interface ChatAdapter {

/** Plain-text direct message — used for blocker escalation pings. */
sendDm(userName: string, text: string): Promise<void>;

/**
* Interactive blocker card (Acknowledge / Add update / Resolve buttons),
* DMed when someone is tagged on a blocker or nudged about one.
*/
sendBlockerCard(userName: string, standup: Standup, blocker: Blocker, note: string): Promise<void>;
}
Loading