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
71 changes: 71 additions & 0 deletions database.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1605,6 +1605,77 @@ export type Database = {
},
]
}
profile_preferences: {
Row: {
area_unit: string | null
co2_unit: string | null
created_at: string
date_format: string | null
distance_unit: string | null
ec_unit: string | null
pressure_unit: string | null
rainfall_unit: string | null
soil_moisture_unit: string | null
temperature_unit: string | null
theme: string | null
time_format: string | null
timezone: string | null
updated_at: string
user_id: string
water_level_unit: string | null
weight_unit: string | null
wind_speed_unit: string | null
}
Insert: {
area_unit?: string | null
co2_unit?: string | null
created_at?: string
date_format?: string | null
distance_unit?: string | null
ec_unit?: string | null
pressure_unit?: string | null
rainfall_unit?: string | null
soil_moisture_unit?: string | null
temperature_unit?: string | null
theme?: string | null
time_format?: string | null
timezone?: string | null
updated_at?: string
user_id: string
water_level_unit?: string | null
weight_unit?: string | null
wind_speed_unit?: string | null
}
Update: {
area_unit?: string | null
co2_unit?: string | null
created_at?: string
date_format?: string | null
distance_unit?: string | null
ec_unit?: string | null
pressure_unit?: string | null
rainfall_unit?: string | null
soil_moisture_unit?: string | null
temperature_unit?: string | null
theme?: string | null
time_format?: string | null
timezone?: string | null
updated_at?: string
user_id?: string
water_level_unit?: string | null
weight_unit?: string | null
wind_speed_unit?: string | null
}
Relationships: [
{
foreignKeyName: "profile_preferences_user_id_fkey"
columns: ["user_id"]
isOneToOne: true
referencedRelation: "profiles"
referencedColumns: ["id"]
},
]
}
profiles: {
Row: {
accepted_agreements: boolean
Expand Down
111 changes: 111 additions & 0 deletions docs/rule-trigger-history.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Rule Trigger History

The "View History" dialog on the Rules page shows, for one rule template, every time
that rule **triggered** on a device and when it later **reset**. It answers: "When did
this alarm fire, on which sensor, what value tripped it, and how long did it stay in
alarm?"

Each row in the log is one **episode**: a trigger event paired with its matching reset
(or no reset yet, meaning the alarm is still active). A single rule template can watch
many devices, so episodes from different devices are interleaved in one chronological
list.

## Where it lives

| Layer | File |
|---|---|
| Dialog component | `src/routes/rules/ViewRuleAlertHistory.svelte` |
| Invoked from | `src/routes/rules/+page.svelte` (row action: `<ViewRuleAlertHistory templateId={row.id} ruleName={row.name} />`) |
| Frontend API call | `ApiService.getRuleTemplateHistory(id)` in `src/lib/api/api.service.ts` |
| DTO | `RuleTriggerLogDto` in `src/lib/api/api.dtos.ts` |
| Backend handler | `RulesNewService.getHistory()` in `api/src/v1/rules-new/rules-new.service.ts` |
| Backend DTO | `api/src/v1/rules-new/dto/rule-trigger-log.dto.ts` |
| Source table | Postgres `cw_rule_trigger_log` |
| i18n keys | `rules_new_history_*` and `rules_new_view_history` in `messages/{en,ja}.json` |

## Data model

`cw_rule_trigger_log` (one row per episode), surfaced as `RuleTriggerLogDto`:

| Field | Meaning |
|---|---|
| `id` | Episode id (used as the `{#each}` key) |
| `devEui` / `deviceName` | The device the rule fired on; `deviceName` resolved server-side, may be null |
| `templateId` | Owning rule template |
| `triggeredAt` / `triggeredValue` | When the condition was first met, and the reading that met it |
| `resetAt` / `resetValue` | When the condition cleared, and the reading at reset. **Null = still active** |
| `createdAt` | Row creation timestamp (not displayed) |

**Active vs resolved is derived purely from `resetAt`:** `resetAt == null` ⇒ active.

## Data flow

1. The history icon button calls `openHistory()`, which opens the dialog and, on first
open per `templateId`, calls `loadHistory()`. (`loadedFor` guards against re-fetching
the same template; loading from the click handler avoids a load-triggering `$effect`.)
2. `getHistory()` on the backend:
- Reuses `findOne()` so a hidden/non-existent template returns 404, not an empty list.
- Restricts rows to devices the caller **can view** (`viewableDevices`), then resolves
`deviceName` from the managed-device list.
- Orders by `triggered_at DESC` and caps at **200 rows**.
3. The component renders the returned episodes.

## The UI (redesign, 2026-06)

The list is a vertical **timeline per episode**:

- **Card left border** encodes overall status — red (`--cw-danger-500`) for active,
green (`--cw-success-500`) for resolved.
- **Card header**: device name + a status chip (`Active` danger / `Resolved` success).
- **Timeline body**: a two-marker vertical track —
- top dot = `Triggered` (time + tripping value),
- connector line carrying the **duration** label in the middle,
- bottom dot = `Reset` (time + reset value) for resolved, or `Still active` for active.
- **Duration**: resolved episodes show `Lasted <span>` using `formatDuration()`; active
episodes show `Active for` + a live-ticking `<CwDuration from={triggeredAt} />`.
`formatDuration()` deliberately mirrors CwDuration's compact format (`13h 30m`,
`2d 04h`, `45m 12s`) so static and live durations read identically.
- **Summary chips** above the list: a danger "N active now" chip (when any are active)
and a "N events" total.
- **Device filter**: a compact `CwDropdown` (shown only when the rule spans more than one
device) with an "All devices" option plus one per device, filtering `filteredEntries`.

### Why it was changed

The previous layout placed the trigger on the far left, the device name small in the
top-right corner, and the reset/"Still active" on the far right with large empty gaps.
Problems it had: the trigger→reset relationship and **how long the alarm lasted were not
shown**; interleaved devices were hard to track because the device label was de-emphasized
and there was no per-device filter; the content was flung to both edges because the dialog
**inherits `text-align: right`** from the table action cell it mounts in (`.rule-history`
now pins `text-align: left`); and it hard-coded hex color fallbacks (`#dc2626`, etc.),
violating the [CWUI-first style contract](./cwui-first-style.md).

The redesign makes each episode a self-contained timeline, surfaces duration, adds the
per-device filter and an active-count summary, and uses only CWUI design tokens.

## Conventions & gotchas

- **No hard-coded colors.** All colors come from CWUI tokens (`--cw-danger-500`,
`--cw-success-500`, `--cw-text-muted`, `--cw-border-muted`, `--cw-bg-surface`, …). Local
CSS handles layout only, per the style contract.
- **i18n.** Every visible string is a `m.rules_new_history_*` key in both `en.json` and
`ja.json`. Add new strings to both. `{duration}` is pre-formatted; `{count}` is stringified.
- **200-row cap & ordering** are backend concerns (`getHistory`). The component does not
paginate; if history needs to grow beyond 200, add paging there first.
- **Value units.** `triggeredValue`/`resetValue` are raw numbers — the rule's metric/unit
is not part of the DTO, so values render unit-less. If units are wanted, plumb them
through `RuleTriggerLogDto` from the rule template.
- **Device visibility** is enforced server-side; the client trusts the returned set.

## Verifying changes

1. `pnpm check` (svelte-check) — must stay at 0 errors.
2. Run the Svelte MCP `svelte-autofixer` on the component until it reports no issues.
3. `pnpm dev`, open the Rules page, click the history icon on a multi-device rule
(e.g. a tunnel-freezer temperature rule). Confirm: active episodes show a live-ticking
"Active for" duration and a red border; resolved episodes show "Lasted …" and a green
border; the device filter narrows the list; the summary counts match.

To exercise it against real data, query `cw_rule_trigger_log` for a template with a mix of
active and reset rows (templates with `reset_at IS NULL` rows have active episodes).
66 changes: 63 additions & 3 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,52 @@
"error_unexpected_title": "Unexpected Error",
"error_unknown": "An unknown error occurred.",
"generic_error": "An unexpected error occurred. Please try again.",
"profile_identity_title": "Profile",
"profile_identity_subtitle": "The account details CropWatch uses across reports, notifications, and shared views.",
"profile_username_label": "Username",
"profile_full_name_label": "Full name",
"profile_employer_label": "Employer",
"profile_website_label": "Website",
"profile_website_invalid": "Enter a valid domain or URL.",
"profile_phone_label": "Phone number",
"profile_email_title": "Email address",
"profile_email_subtitle": "Changing your email sends a confirmation link to the new address. The change applies once you confirm.",
"profile_email_pending": "Confirmation sent. Check your inbox to complete the change.",
"profile_email_label": "Email",
"profile_email_send_action": "Send confirmation",
"profile_email_locked": "CropWatch email addresses (cropwatch.io / cropwatch.co.jp) can't be changed here. Contact an administrator if you need to update it.",
"profile_email_invalid": "Enter a valid email address.",
"profile_saved": "Profile updated.",
"profile_email_sent": "Confirmation email sent.",
"settings_regional_title": "Regional",
"settings_regional_subtitle": "Language, timezone, and calendar notation used across the app.",
"settings_timezone_label": "Home timezone",
"settings_timezone_placeholder": "Select a timezone",
"settings_appearance_title": "Appearance",
"settings_appearance_subtitle": "Theme and spatial notation for the interface.",
"settings_theme_label": "Theme mode",
"settings_theme_dark": "Dark",
"settings_theme_light": "Light",
"settings_theme_system": "System",
"settings_units_title": "Measurement units",
"settings_units_subtitle": "Choose the units you expect when reading sensor data.",
"settings_temperature_label": "Temperature",
"settings_ec_label": "Electrical conductivity",
"settings_water_level_label": "Water level",
"settings_weight_label": "Weight",
"settings_date_format_label": "Date format",
"settings_time_format_label": "Time format",
"settings_distance_label": "Distance",
"settings_area_label": "Area",
"settings_soil_moisture_label": "Soil moisture",
"settings_pressure_label": "Pressure",
"settings_rainfall_label": "Rainfall",
"settings_wind_speed_label": "Wind speed",
"settings_co2_label": "CO₂",
"settings_units_pending_note": "Additional units below are coming soon and are not saved yet.",
"settings_actions_title": "Save preferences",
"settings_reset": "Reset",
"settings_saved": "Preferences saved.",
"error_reset_and_login": "Reset & Sign In",
"offline_page_title": "CropWatch Offline",
"offline_page_description": "CropWatch is offline. Reconnect to load live device data, reports, and settings.",
Expand Down Expand Up @@ -400,6 +446,13 @@
"rules_new_history_loading": "Loading history…",
"rules_new_history_empty": "This rule template has not triggered yet.",
"rules_new_history_load_failed": "Unable to load rule history.",
"rules_new_history_status_active": "Active",
"rules_new_history_status_resolved": "Resolved",
"rules_new_history_duration_lasted": "Lasted {duration}",
"rules_new_history_duration_active": "Active for",
"rules_new_history_active_summary": "{count} active now",
"rules_new_history_total_events": "{count} events",
"rules_new_history_filter_all": "All devices",
"reports_page_title": "Reports - CropWatch",
"reports_weekly_reports": "Weekly Reports",
"reports_for_device": "For Device",
Expand Down Expand Up @@ -557,9 +610,11 @@
"reports_create_operator_range": "is between",
"reports_create_method_email": "Email",
"reports_create_method_sms": "Text message",
"reports_schedule_card_title": "Data Processing Schedules",
"reports_schedule_card_title": "(ADVANCED) Data Processing Schedules",
"reports_schedule_card_subtitle": "Configure what data to include or exclude based on time ranges.",
"reports_schedule_card_copy": "Add one or more time windows to control which readings are included in this report.",
"reports_schedule_card_description": "Data processing schedules control which sensor readings are counted in this report, based on the day of the week and the time of day. Each schedule is a time window plus a rule that either includes or excludes the readings that fall inside it. For example, include only operating hours (09:00 to 17:00, Monday to Friday), or exclude overnight periods when equipment is deliberately powered down. With no schedules, every reading in the reporting period is used. Each window is evaluated in the timezone you set and can cross midnight.",
"reports_schedule_card_warning": "If you do not know what this is, please reach out to support before adding any settings here. Incorrect schedules can silently remove data from your reports.",
"reports_schedule_empty": "No data processing schedules yet. Add one to filter report data by time.",
"reports_schedule_entry_heading": "Schedule {index}",
"reports_schedule_day_of_week": "Day of week",
Expand Down Expand Up @@ -715,10 +770,14 @@
"devices_save_note_success": "Note saved successfully.",
"devices_sensor_certificate_note": "Downloads the individual ISO17025 PDF returned by Sensirion Libellus for this serial number.",
"devices_sensor_certificate_requires_login": "You must be logged in to download sensor certificates.",
"devices_sensor_certificate_unsupported_device": "This device does not support Sensirion Libellus calibration certificates.",
"devices_sensor_certificates_subtitle": "Download the Libellus PDF certificate for each configured sensor serial.",
"devices_sensor_certificates_title": "Device Sensor Certificates",
"devices_sensor_one": "Sensor 1",
"devices_sensor_serial_chip": "Serial {serial}",
"devices_sensor_series_certificate_label": "SHT4x series certificate",
"devices_sensor_series_certificate_note": "Downloads the generic SHT4x-series ISO calibration certificate. This is not specific to a serial number.",
"devices_sensor_sht43_label": "SHT43",
"devices_sensor_two": "Sensor 2",
"devices_settings_page_title": "Device Settings | {devEui} | CropWatch",
"devices_settings_subtitle": "Update the cw_device name and group fields.",
Expand Down Expand Up @@ -1160,8 +1219,9 @@
"reports_new_cadence_month_label": "End of month",
"reports_new_cadence_month_description": "Generate a report at the end of each month.",
"reports_new_cadence_utc_offset": "UTC offset (hours)",
"reports_new_cadence_active_label": "Active cadence",
"reports_new_cadence_active_description": "Disable to pause report generation without removing the schedule.",
"reports_new_cadence_active_label_on": "Report generation (ON)",
"reports_new_cadence_active_label_off": "Report generation (OFF)",
"reports_new_cadence_active_description": "Turn off to pause this report. You can turn it back on anytime.",
"reports_new_created_success": "Report template \"{name}\" created successfully.",
"reports_new_updated_success": "Report template \"{name}\" updated successfully.",
"reports_new_deleted_success": "Report template \"{name}\" deleted successfully.",
Expand Down
Loading