feat(status): built-in and custom status presets

The Status Message field required typing every status from scratch. Add
a one-click preset row:

- Built-in "Quick statuses" spanning gaming, social, life and work
  (Gaming, In a party, Ranked grind, AFK, Watching, In a meeting,
  Working remotely, Lunch, On vacation, Out sick...), each carrying a
  suggested auto-clear so a click sets the message and the timer at once.
- Custom presets: save the current status as a reusable preset, stored
  in io.lotus.status_presets account data (synced across devices via the
  shared account-data list store), de-duped by normalized label, capped
  at 20, deletable inline.

The existing save path is factored into a shared applyStatus() used by
the Save button and by preset apply, so server writes, the status
localStorage keys, and the auto-clear expiry bookkeeping stay identical.
Ordering/de-dupe logic is pure in utils/statusPresets.ts (upsertPreset,
normalizeLabel) with unit tests; no change to the presence wire format,
expiry monitor, or presence-mode selector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 23:30:08 -04:00
co-authored by Claude Opus 4.8
parent 39e75f4eea
commit d0614710b0
5 changed files with 282 additions and 20 deletions
+52
View File
@@ -0,0 +1,52 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { upsertPreset, normalizeLabel, StatusPreset } from './statusPresets';
const p = (id: string, label: string, clearAfter = '0'): StatusPreset => ({ id, label, clearAfter });
test('normalizeLabel trims and lowercases', () => {
assert.equal(normalizeLabel(' 🎮 Gaming '), '🎮 gaming');
assert.equal(normalizeLabel('AFK'), 'afk');
});
test('upsertPreset prepends a new preset', () => {
const list = [p('1', 'a'), p('2', 'b')];
const out = upsertPreset(list, p('3', 'c'));
assert.deepEqual(
out.map((x) => x.id),
['3', '1', '2'],
);
});
test('upsertPreset de-dupes by normalized label, moving the entry to the front', () => {
const list = [p('1', 'Gaming'), p('2', 'b'), p('3', 'c')];
// Same label (different case/whitespace) → old entry removed, new one at front.
const out = upsertPreset(list, p('9', ' gaming '));
assert.deepEqual(
out.map((x) => x.id),
['9', '2', '3'],
);
assert.equal(out.filter((x) => normalizeLabel(x.label) === 'gaming').length, 1);
});
test('upsertPreset enforces the cap, dropping the oldest', () => {
const list = Array.from({ length: 20 }, (_, i) => p(String(i), `label-${i}`));
const out = upsertPreset(list, p('new', 'fresh'), 20);
assert.equal(out.length, 20);
assert.equal(out[0].id, 'new');
// The last (oldest) entry, id '19', is dropped.
assert.equal(
out.some((x) => x.id === '19'),
false,
);
});
test('upsertPreset does not mutate its input', () => {
const list = [p('1', 'a'), p('2', 'b')];
const before = list.map((x) => x.id);
upsertPreset(list, p('3', 'c'));
assert.deepEqual(
list.map((x) => x.id),
before,
);
});
+58
View File
@@ -0,0 +1,58 @@
// Status presets — quick-pick statuses for the profile Status Message field.
//
// A preset's `label` is the full status string (it may include a leading emoji);
// `clearAfter` is one of the CLEAR_AFTER_OPTIONS values used by ProfileStatus
// ('0' = never, 'today' = until midnight, or a milliseconds string), so applying
// a preset feeds the existing getMsFromOption path unchanged.
export type StatusPreset = {
id: string;
label: string;
clearAfter: string;
};
const HOUR = String(60 * 60 * 1000);
const MIN30 = String(30 * 60 * 1000);
const HOUR4 = String(4 * 60 * 60 * 1000);
const DAY7 = String(7 * 24 * 60 * 60 * 1000);
// Built-in presets span gaming, social, and life — not just work — since Lotus is
// used mostly for gaming but for all use cases. Order groups related ones together.
export const BUILT_IN_STATUS_PRESETS: StatusPreset[] = [
{ id: 'builtin-gaming', label: '🎮 Gaming', clearAfter: HOUR4 },
{ id: 'builtin-party', label: '🎧 In a party', clearAfter: HOUR4 },
{ id: 'builtin-ranked', label: '🏆 Ranked grind', clearAfter: HOUR4 },
{ id: 'builtin-afk', label: '😴 AFK', clearAfter: MIN30 },
{ id: 'builtin-watching', label: '🍿 Watching', clearAfter: HOUR4 },
{ id: 'builtin-lunch', label: '🍽️ Lunch', clearAfter: MIN30 },
{ id: 'builtin-meeting', label: '🗓️ In a meeting', clearAfter: HOUR },
{ id: 'builtin-remote', label: '🏠 Working remotely', clearAfter: 'today' },
{ id: 'builtin-focusing', label: '🎯 Focusing', clearAfter: HOUR },
{ id: 'builtin-vacation', label: '🌴 On vacation', clearAfter: DAY7 },
{ id: 'builtin-sick', label: '🤒 Out sick', clearAfter: 'today' },
];
/** Normalize a label for de-dupe: trim + lowercase (emoji preserved). */
export function normalizeLabel(label: string): string {
return label.trim().toLowerCase();
}
/** Stable-enough unique id for a custom preset (used as a React key). */
export function makePresetId(): string {
return `preset-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
/**
* Insert a preset at the front of the list, de-duped by normalized label so
* re-saving the same status moves it to the front instead of duplicating, and
* capped at `max`. Pure — returns a new array and never mutates the input.
*/
export function upsertPreset(
list: StatusPreset[],
preset: StatusPreset,
max = 20,
): StatusPreset[] {
const key = normalizeLabel(preset.label);
const withoutDup = list.filter((p) => normalizeLabel(p.label) !== key);
return [preset, ...withoutDup].slice(0, max);
}