diff --git a/frontend/src/lib/i18n/en.json b/frontend/src/lib/i18n/en.json
index 8625244..cd8bfa1 100644
--- a/frontend/src/lib/i18n/en.json
+++ b/frontend/src/lib/i18n/en.json
@@ -268,6 +268,7 @@
"preview": "Preview",
"variables": "Variables",
"assetFields": "Asset fields (in {% for asset in added_assets %})",
+ "albumFields": "Album fields (in {% for album in albums %})",
"confirmDelete": "Delete this template config?"
},
"templateVars": {
@@ -275,38 +276,50 @@
"message_assets_removed": { "description": "Notification when assets are removed from an album" },
"message_album_renamed": { "description": "Notification when an album is renamed" },
"message_album_deleted": { "description": "Notification when an album is deleted" },
- "periodic_summary_message": { "description": "Periodic album summary with stats" },
- "scheduled_assets_message": { "description": "Scheduled asset picks from albums" },
- "memory_mode_message": { "description": "\"On This Day\" memories from past years" },
+ "periodic_summary_message": { "description": "Periodic album summary (scheduler not yet implemented)" },
+ "scheduled_assets_message": { "description": "Scheduled asset delivery (scheduler not yet implemented)" },
+ "memory_mode_message": { "description": "\"On This Day\" memories (scheduler not yet implemented)" },
+ "album_id": "Album ID (UUID)",
"album_name": "Album name",
- "album_url": "Public share URL (if available)",
+ "album_url": "Public share URL (empty if not shared)",
"added_count": "Number of assets added",
"removed_count": "Number of assets removed",
- "change_type": "Type of change",
- "people": "Detected people names (use {{ people | join(', ') }})",
- "added_assets": "List of added asset objects (use {% for asset in added_assets %})",
- "removed_assets": "List of removed asset IDs",
- "shared": "Whether album is shared (true/false)",
- "video_warning": "Video size warning text",
- "old_name": "Previous album name",
- "new_name": "New album name",
- "albums": "List of album objects (use {% for album in albums %})",
- "assets": "List of asset objects (use {% for asset in assets %})",
- "date": "Current date/time",
+ "change_type": "Type of change (assets_added, assets_removed, album_renamed, album_deleted)",
+ "people": "Detected people names (list, use {{ people | join(', ') }})",
+ "added_assets": "List of asset dicts (use {% for asset in added_assets %})",
+ "removed_assets": "List of removed asset IDs (strings)",
+ "shared": "Whether album is shared (boolean)",
+ "video_warning": "Video size warning (from template config, only if videos present)",
+ "old_name": "Previous album name (rename events)",
+ "new_name": "New album name (rename events)",
+ "old_shared": "Was album shared before rename (boolean)",
+ "new_shared": "Is album shared after rename (boolean)",
+ "albums": "List of album dicts (use {% for album in albums %})",
+ "assets": "List of asset dicts (use {% for asset in assets %})",
+ "date": "Current date string",
+ "asset_id": "Asset ID (UUID)",
"asset_filename": "Original filename",
"asset_type": "IMAGE or VIDEO",
"asset_created_at": "Creation date/time (ISO 8601)",
"asset_owner": "Owner display name",
+ "asset_owner_id": "Owner user ID",
"asset_description": "User or EXIF description",
- "asset_url": "Public viewer URL",
- "asset_download_url": "Direct download URL",
- "asset_photo_url": "Preview image URL (images only)",
- "asset_playback_url": "Video playback URL (videos only)",
- "asset_is_favorite": "Whether asset is favorited",
+ "asset_people": "People detected in this asset (list)",
+ "asset_is_favorite": "Whether asset is favorited (boolean)",
"asset_rating": "Star rating (1-5 or null)",
+ "asset_latitude": "GPS latitude (float or null)",
+ "asset_longitude": "GPS longitude (float or null)",
"asset_city": "City name",
"asset_state": "State/region name",
- "asset_country": "Country name"
+ "asset_country": "Country name",
+ "asset_url": "Public viewer URL (if shared)",
+ "asset_download_url": "Direct download URL (if shared)",
+ "asset_photo_url": "Preview image URL (images only, if shared)",
+ "asset_playback_url": "Video playback URL (videos only, if shared)",
+ "album_name_field": "Album name (in album list)",
+ "album_asset_count": "Total assets in album",
+ "album_url_field": "Album share URL",
+ "album_shared": "Whether album is shared"
},
"hints": {
"periodicSummary": "Sends a scheduled summary of all tracked albums at specified times. Great for daily/weekly digests.",
diff --git a/frontend/src/lib/i18n/ru.json b/frontend/src/lib/i18n/ru.json
index cbf6425..f116815 100644
--- a/frontend/src/lib/i18n/ru.json
+++ b/frontend/src/lib/i18n/ru.json
@@ -268,6 +268,7 @@
"preview": "Предпросмотр",
"variables": "Переменные",
"assetFields": "Поля файла (в {% for asset in added_assets %})",
+ "albumFields": "Поля альбома (в {% for album in albums %})",
"confirmDelete": "Удалить эту конфигурацию шаблона?"
},
"templateVars": {
@@ -275,38 +276,50 @@
"message_assets_removed": { "description": "Уведомление об удалении файлов из альбома" },
"message_album_renamed": { "description": "Уведомление о переименовании альбома" },
"message_album_deleted": { "description": "Уведомление об удалении альбома" },
- "periodic_summary_message": { "description": "Периодическая сводка альбомов со статистикой" },
- "scheduled_assets_message": { "description": "Запланированная подборка фото из альбомов" },
- "memory_mode_message": { "description": "«В этот день» — фото из прошлых лет" },
+ "periodic_summary_message": { "description": "Периодическая сводка альбомов (планировщик не реализован)" },
+ "scheduled_assets_message": { "description": "Запланированная подборка фото (планировщик не реализован)" },
+ "memory_mode_message": { "description": "«В этот день» — воспоминания (планировщик не реализован)" },
+ "album_id": "ID альбома (UUID)",
"album_name": "Название альбома",
- "album_url": "Публичная ссылка (если есть)",
+ "album_url": "Публичная ссылка (пусто, если не расшарен)",
"added_count": "Количество добавленных файлов",
"removed_count": "Количество удалённых файлов",
- "change_type": "Тип изменения",
- "people": "Обнаруженные люди ({{ people | join(', ') }})",
- "added_assets": "Список добавленных файлов ({% for asset in added_assets %})",
- "removed_assets": "Список ID удалённых файлов",
- "shared": "Общий альбом (true/false)",
- "video_warning": "Предупреждение о размере видео",
- "old_name": "Прежнее название альбома",
- "new_name": "Новое название альбома",
+ "change_type": "Тип изменения (assets_added, assets_removed, album_renamed, album_deleted)",
+ "people": "Обнаруженные люди (список, {{ people | join(', ') }})",
+ "added_assets": "Список файлов ({% for asset in added_assets %})",
+ "removed_assets": "Список ID удалённых файлов (строки)",
+ "shared": "Общий альбом (boolean)",
+ "video_warning": "Предупреждение о видео (из конфига шаблона, если есть видео)",
+ "old_name": "Прежнее название альбома (при переименовании)",
+ "new_name": "Новое название альбома (при переименовании)",
+ "old_shared": "Был ли общим до переименования (boolean)",
+ "new_shared": "Является ли общим после переименования (boolean)",
"albums": "Список альбомов ({% for album in albums %})",
"assets": "Список файлов ({% for asset in assets %})",
- "date": "Текущая дата/время",
+ "date": "Текущая дата",
+ "asset_id": "ID файла (UUID)",
"asset_filename": "Имя файла",
"asset_type": "IMAGE или VIDEO",
"asset_created_at": "Дата создания (ISO 8601)",
"asset_owner": "Имя владельца",
+ "asset_owner_id": "ID владельца",
"asset_description": "Описание (EXIF или пользовательское)",
- "asset_url": "Ссылка для просмотра",
- "asset_download_url": "Ссылка для скачивания",
- "asset_photo_url": "URL превью (только фото)",
- "asset_playback_url": "URL видео (только видео)",
- "asset_is_favorite": "В избранном",
+ "asset_people": "Люди на этом файле (список)",
+ "asset_is_favorite": "В избранном (boolean)",
"asset_rating": "Рейтинг (1-5 или null)",
+ "asset_latitude": "GPS широта (float или null)",
+ "asset_longitude": "GPS долгота (float или null)",
"asset_city": "Город",
"asset_state": "Регион",
- "asset_country": "Страна"
+ "asset_country": "Страна",
+ "asset_url": "Ссылка для просмотра (если расшарен)",
+ "asset_download_url": "Ссылка для скачивания (если расшарен)",
+ "asset_photo_url": "URL превью (только фото, если расшарен)",
+ "asset_playback_url": "URL видео (только видео, если расшарен)",
+ "album_name_field": "Название альбома (в списке альбомов)",
+ "album_asset_count": "Всего файлов в альбоме",
+ "album_url_field": "Ссылка на альбом",
+ "album_shared": "Общий альбом"
},
"hints": {
"periodicSummary": "Отправляет плановую сводку по всем отслеживаемым альбомам в указанное время. Подходит для ежедневных/еженедельных дайджестов.",
diff --git a/frontend/src/routes/template-configs/+page.svelte b/frontend/src/routes/template-configs/+page.svelte
index 4be01dc..a527b65 100644
--- a/frontend/src/routes/template-configs/+page.svelte
+++ b/frontend/src/routes/template-configs/+page.svelte
@@ -274,10 +274,10 @@
{/each}
- {#if varsRef[showVarsFor].asset_fields}
+ {#if varsRef[showVarsFor].asset_fields && typeof varsRef[showVarsFor].asset_fields === 'object'}
{t('templateConfig.assetFields')}:
- {#each Object.entries(varsRef[showVarsFor].asset_fields || {}) as [name, desc]}
+ {#each Object.entries(varsRef[showVarsFor].asset_fields) as [name, desc]}
{'{{ asset.' + name + ' }}'}
{t(`templateVars.asset_${name}`, desc as string)}
@@ -285,5 +285,16 @@
{/each}
{/if}
+ {#if varsRef[showVarsFor].album_fields && typeof varsRef[showVarsFor].album_fields === 'object'}
+
+
{t('templateConfig.albumFields')}:
+ {#each Object.entries(varsRef[showVarsFor].album_fields) as [name, desc]}
+
+ {'{{ album.' + name + ' }}'}
+ {t(`templateVars.album_${name}`, desc as string)}
+
+ {/each}
+
+ {/if}
{/if}
diff --git a/packages/server/src/immich_watcher_server/api/template_configs.py b/packages/server/src/immich_watcher_server/api/template_configs.py
index 84b5008..9a4d479 100644
--- a/packages/server/src/immich_watcher_server/api/template_configs.py
+++ b/packages/server/src/immich_watcher_server/api/template_configs.py
@@ -14,22 +14,37 @@ from ..database.models import TemplateConfig, User
router = APIRouter(prefix="/api/template-configs", tags=["template-configs"])
-# Sample asset for template preview
+# Sample asset matching what build_asset_detail() actually returns
_SAMPLE_ASSET = {
+ "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"filename": "IMG_001.jpg",
"type": "IMAGE",
"created_at": "2026-03-19T10:30:00",
"owner": "Alice",
+ "owner_id": "user-uuid-1",
"description": "Family picnic",
- "url": "https://immich.example.com/photos/abc123",
- "download_url": "https://immich.example.com/api/assets/abc123/original",
- "photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
- "playback_url": "",
+ "people": ["Alice", "Bob"],
"is_favorite": True,
"rating": 5,
+ "latitude": 48.8566,
+ "longitude": 2.3522,
"city": "Paris",
"state": "Île-de-France",
"country": "France",
+ "url": "https://immich.example.com/photos/abc123",
+ "download_url": "https://immich.example.com/api/assets/abc123/original",
+ "photo_url": "https://immich.example.com/api/assets/abc123/thumbnail",
+}
+
+_SAMPLE_VIDEO_ASSET = {
+ **_SAMPLE_ASSET,
+ "id": "d4e5f6a7-b8c9-0123-defg-456789abcdef",
+ "filename": "VID_002.mp4",
+ "type": "VIDEO",
+ "is_favorite": False,
+ "rating": None,
+ "photo_url": None,
+ "playback_url": "https://immich.example.com/api/assets/def456/video",
}
_SAMPLE_ALBUM = {
@@ -39,23 +54,26 @@ _SAMPLE_ALBUM = {
"shared": True,
}
-# Full context covering all possible template variables
+# Full context covering ALL possible template variables from _build_event_data()
_SAMPLE_CONTEXT = {
- # Event variables
+ # Core event fields (always present)
+ "album_id": "b2eeeaa4-bba0-477a-a06f-5cb9e21818e8",
"album_name": "Family Photos",
"album_url": "https://immich.example.com/share/abc123",
+ "change_type": "assets_added",
"added_count": 3,
"removed_count": 1,
- "change_type": "assets_added",
- "people": ["Alice", "Bob"],
- "added_assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "VID_002.mp4", "type": "VIDEO", "is_favorite": False, "rating": None, "playback_url": "https://immich.example.com/api/assets/def456/video"}],
+ "added_assets": [_SAMPLE_ASSET, _SAMPLE_VIDEO_ASSET],
"removed_assets": ["asset-id-1", "asset-id-2"],
+ "people": ["Alice", "Bob"],
"shared": True,
"video_warning": "\n\n⚠️ Note: Videos may not be sent due to Telegram's 50 MB file size limit.",
- # Rename variables
+ # Rename fields (always present, empty for non-rename events)
"old_name": "Old Album",
"new_name": "New Album",
- # Scheduled/periodic variables
+ "old_shared": False,
+ "new_shared": True,
+ # Scheduled/periodic variables (for those templates)
"albums": [_SAMPLE_ALBUM, {**_SAMPLE_ALBUM, "name": "Vacation 2025", "asset_count": 120}],
"assets": [_SAMPLE_ASSET, {**_SAMPLE_ASSET, "filename": "IMG_002.jpg", "city": "London", "country": "UK"}],
"date": "2026-03-19",
diff --git a/packages/server/src/immich_watcher_server/api/template_vars.py b/packages/server/src/immich_watcher_server/api/template_vars.py
index 5d59580..bcce7fa 100644
--- a/packages/server/src/immich_watcher_server/api/template_vars.py
+++ b/packages/server/src/immich_watcher_server/api/template_vars.py
@@ -1,87 +1,127 @@
-"""Template variable reference for all template slots."""
+"""Template variable reference for all template slots.
+
+This must match what watcher._build_event_data() and
+core.asset_utils.build_asset_detail() actually produce.
+"""
+
+_ASSET_FIELDS = {
+ "id": "Asset ID (UUID)",
+ "filename": "Original filename",
+ "type": "IMAGE or VIDEO",
+ "created_at": "Creation date/time (ISO 8601)",
+ "owner": "Owner display name",
+ "owner_id": "Owner user ID",
+ "description": "User description or EXIF description",
+ "people": "People detected in this asset (list)",
+ "is_favorite": "Whether asset is favorited (boolean)",
+ "rating": "Star rating (1-5 or null)",
+ "latitude": "GPS latitude (float or null)",
+ "longitude": "GPS longitude (float or null)",
+ "city": "City name",
+ "state": "State/region name",
+ "country": "Country name",
+ "url": "Public viewer URL (if shared)",
+ "download_url": "Direct download URL (if shared)",
+ "photo_url": "Preview image URL (images only, if shared)",
+ "playback_url": "Video playback URL (videos only, if shared)",
+}
+
+_ALBUM_FIELDS = {
+ "name": "Album name",
+ "asset_count": "Total number of assets",
+ "url": "Public share URL",
+ "shared": "Whether album is shared",
+}
TEMPLATE_VARIABLES: dict[str, dict] = {
"message_assets_added": {
"description": "Notification when new assets are added to an album",
"variables": {
+ "album_id": "Album ID (UUID)",
"album_name": "Album name",
- "album_url": "Public share URL (if available)",
+ "album_url": "Public share URL (empty if not shared)",
+ "change_type": "Always 'assets_added'",
"added_count": "Number of assets added",
- "removed_count": "Number of assets removed",
- "change_type": "Type of change (assets_added)",
- "people": "List of detected people names (use {{ people | join(', ') }})",
- "added_assets": "List of asset dicts (use {% for asset in added_assets %})",
- "shared": "Whether album is shared (true/false)",
- "video_warning": "Video size warning text (if videos present)",
- },
- "asset_fields": {
- "filename": "Original filename",
- "type": "IMAGE or VIDEO",
- "created_at": "Creation date/time (ISO 8601)",
- "owner": "Owner display name",
- "description": "User description or EXIF description",
- "url": "Public viewer URL",
- "download_url": "Direct download URL",
- "photo_url": "Preview image URL (images only)",
- "playback_url": "Video playback URL (videos only)",
- "is_favorite": "Whether asset is favorited (boolean)",
- "rating": "Star rating (1-5 or null)",
- "city": "City name",
- "state": "State/region name",
- "country": "Country name",
- "people": "People detected in this asset (list)",
+ "removed_count": "Always 0",
+ "added_assets": "List of asset dicts ({% for asset in added_assets %})",
+ "removed_assets": "Always empty list",
+ "people": "Detected people across all added assets (list of strings)",
+ "shared": "Whether album is shared (boolean)",
+ "video_warning": "Video size warning text (set from template config if videos present)",
+ "old_name": "Always empty (for rename events)",
+ "new_name": "Always empty (for rename events)",
},
+ "asset_fields": _ASSET_FIELDS,
},
"message_assets_removed": {
- "description": "Notification when assets are removed",
+ "description": "Notification when assets are removed from an album",
"variables": {
+ "album_id": "Album ID (UUID)",
"album_name": "Album name",
- "album_url": "Public share URL",
+ "album_url": "Public share URL (empty if not shared)",
+ "change_type": "Always 'assets_removed'",
+ "added_count": "Always 0",
"removed_count": "Number of assets removed",
- "removed_assets": "List of removed asset IDs",
- "change_type": "Type of change (assets_removed)",
+ "added_assets": "Always empty list",
+ "removed_assets": "List of removed asset IDs (strings)",
+ "people": "People in the album (list of strings)",
+ "shared": "Whether album is shared (boolean)",
+ "video_warning": "Always empty",
+ "old_name": "Always empty",
+ "new_name": "Always empty",
},
},
"message_album_renamed": {
- "description": "Notification when album is renamed",
+ "description": "Notification when an album is renamed",
"variables": {
+ "album_id": "Album ID (UUID)",
+ "album_name": "Current album name (same as new_name)",
+ "album_url": "Public share URL (empty if not shared)",
+ "change_type": "Always 'album_renamed'",
"old_name": "Previous album name",
"new_name": "New album name",
- "album_url": "Public share URL",
+ "old_shared": "Was album shared before (boolean)",
+ "new_shared": "Is album shared now (boolean)",
+ "shared": "Whether album is currently shared",
+ "people": "People in the album (list)",
+ "added_count": "Always 0",
+ "removed_count": "Always 0",
},
},
"message_album_deleted": {
- "description": "Notification when album is deleted",
+ "description": "Notification when an album is deleted",
"variables": {
- "album_name": "Album name",
+ "album_id": "Album ID (UUID)",
+ "album_name": "Album name (before deletion)",
+ "change_type": "Always 'album_deleted'",
+ "shared": "Whether album was shared",
},
},
"periodic_summary_message": {
- "description": "Periodic album summary",
+ "description": "Periodic album summary (not yet implemented in scheduler)",
"variables": {
- "albums": "List of album dicts (use {% for album in albums %})",
- },
- "album_fields": {
- "name": "Album name",
- "asset_count": "Number of assets",
- "url": "Public share URL",
+ "albums": "List of album dicts ({% for album in albums %})",
+ "date": "Current date string",
},
+ "album_fields": _ALBUM_FIELDS,
},
"scheduled_assets_message": {
- "description": "Scheduled asset delivery",
+ "description": "Scheduled asset delivery (not yet implemented in scheduler)",
"variables": {
"album_name": "Album name (empty in combined mode)",
"album_url": "Public share URL",
- "assets": "List of asset dicts (use {% for asset in assets %})",
+ "assets": "List of asset dicts ({% for asset in assets %})",
+ "date": "Current date string",
},
- "asset_fields": "(same as message_assets_added.asset_fields)",
+ "asset_fields": _ASSET_FIELDS,
},
"memory_mode_message": {
- "description": "On This Day memory notification",
+ "description": "On This Day memory notification (not yet implemented in scheduler)",
"variables": {
"album_name": "Album name (empty in combined mode)",
- "assets": "List of asset dicts (use {% for asset in assets %})",
+ "assets": "List of asset dicts ({% for asset in assets %})",
+ "date": "Current date string",
},
- "asset_fields": "(same as message_assets_added.asset_fields)",
+ "asset_fields": _ASSET_FIELDS,
},
}
diff --git a/plans/phase-11-snackbar-notifications.md b/plans/phase-11-snackbar-notifications.md
new file mode 100644
index 0000000..0ce6282
--- /dev/null
+++ b/plans/phase-11-snackbar-notifications.md
@@ -0,0 +1,96 @@
+# Phase 11: Snackbar Notifications
+
+**Status**: Pending
+**Parent**: [primary-plan.md](primary-plan.md)
+
+---
+
+## Goal
+
+Replace browser `alert()` calls, silent failures, and inconsistent error handling with a unified snackbar/toast notification system. Every user-triggered action (save, delete, test, etc.) should provide clear visual feedback via a dismissable snackbar that appears at the bottom of the screen.
+
+---
+
+## Design
+
+### Snackbar Types
+
+| Type | Color | Icon | Auto-dismiss | Use Case |
+|---|---|---|---|---|
+| `success` | Green | `mdiCheckCircle` | 3s | Save, delete, test passed |
+| `error` | Red | `mdiAlertCircle` | 5s (or manual) | API errors, validation failures |
+| `info` | Blue | `mdiInformation` | 3s | Status updates, copy-to-clipboard |
+| `warning` | Amber | `mdiAlert` | 4s | Non-critical issues, deprecation notices |
+
+### Behavior
+
+- Appears at bottom-center of viewport, above the mobile nav bar
+- Stacks vertically (newest on top, max 3 visible)
+- Slide-up entrance, fade-out exit animation
+- Manual dismiss via X button on all types
+- Errors with detail: expandable to show full error message
+- `position: fixed` with inline styles (per project convention)
+
+---
+
+## Implementation Plan
+
+### Task 1: Create Snackbar Store + Component
+
+**Files to create:**
+- `frontend/src/lib/stores/snackbar.svelte.ts` — reactive store using `$state`
+ - `addSnack(type, message, options?)` — push notification
+ - `removeSnack(id)` — dismiss
+ - `snacks` — reactive array of active notifications
+ - Auto-dismiss timer per snack
+
+- `frontend/src/lib/components/Snackbar.svelte` — renders snack stack
+ - Fixed position at bottom-center
+ - Svelte transitions (fly + fade)
+ - Icon per type, dismiss button, optional detail expand
+ - Responsive: full-width on mobile, max-width on desktop
+
+### Task 2: Mount Snackbar in Layout
+
+**Files to modify:**
+- `frontend/src/routes/+layout.svelte` — add `
` component (renders globally)
+
+### Task 3: Replace All Alert/Silent Patterns
+
+**Files to modify (each page):**
+
+| Page | Current Pattern | Replace With |
+|---|---|---|
+| `servers/+page.svelte` | `alert()` on error, silent success | `snack.success('Server saved')`, `snack.error(err.message)` |
+| `trackers/+page.svelte` | `alert()` on error, silent success | `snack.success('Tracker created')`, etc. |
+| `targets/+page.svelte` | `alert()` on error, silent success | `snack.success('Target saved')`, `snack.error(...)` |
+| `template-configs/+page.svelte` | `alert()` on error, silent success | `snack.success('Template saved')`, etc. |
+| `tracking-configs/+page.svelte` | `alert()` on error, silent success | `snack.success('Config saved')`, etc. |
+| `telegram-bots/+page.svelte` | `alert()` on error, silent success | `snack.success('Bot registered')`, etc. |
+| `users/+page.svelte` | `alert()` on error, silent success | `snack.success('User updated')`, etc. |
+| `login/+page.svelte` | `alert()` on error | `snack.error('Invalid credentials')` |
+
+### Task 4: Add i18n Keys
+
+**Files to modify:**
+- `frontend/src/lib/i18n/en.json` — add `snack.*` keys for all messages
+- `frontend/src/lib/i18n/ru.json` — Russian translations
+
+### Task 5: Add Snackbar to API Helper
+
+**Files to create/modify:**
+- Consider a shared `api.ts` helper that wraps `fetch()` and auto-shows error snackbars on non-2xx responses, reducing boilerplate in each page.
+
+---
+
+## Acceptance Criteria
+
+- [ ] Every create/update/delete action shows a success snackbar
+- [ ] Every API error shows an error snackbar with the server's message
+- [ ] No remaining `alert()` calls in the codebase
+- [ ] Snackbars auto-dismiss (success: 3s, error: 5s)
+- [ ] Snackbars are accessible (role="alert", aria-live)
+- [ ] Stacking works correctly (max 3, newest on top)
+- [ ] Animations are smooth (slide-up in, fade out)
+- [ ] Mobile: snackbar appears above bottom nav bar
+- [ ] All snackbar messages are i18n'd (EN + RU)
diff --git a/plans/primary-plan.md b/plans/primary-plan.md
index d12220b..1f9f895 100644
--- a/plans/primary-plan.md
+++ b/plans/primary-plan.md
@@ -222,6 +222,12 @@ async def _execute_telegram_notification(self, ...):
- Natural language tracker configuration via Telegram chat
- **Subplan**: `plans/phase-6-claude-ai-bot.md`
+### Phase 11: Snackbar Notifications `[ ]`
+- Unified toast/snackbar system for action feedback (success, error, info, warning)
+- Replace all `alert()` calls with typed snackbars
+- Auto-dismiss, stacking, accessible, animated
+- **Subplan**: `plans/phase-11-snackbar-notifications.md`
+
---
## Verification