Skip to content
Open
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Change log

## Unreleased

### Added

- **Call natures — per-category force expiry**
- Each call-nature category now has a **Force expire (minutes)** setting (`0` = never), edited from the admin **Options → Incident Mapping → Call Natures** section.
- Incidents in a category with a force-expire value are removed from the live incident map that many minutes after dispatch, **overriding** the viewer's selected time range.
- Category cards show an **Expires *N* min** badge. Disabled categories never expire incidents. Blank natures (shown on the map as `UNKNOWN PROBLEM`) expire when the `UNKNOWN PROBLEM` category has a force-expire value.
- Value is clamped server-side to `0–10080` (7 days); a `since`-less `/api/incidents` request is floored to a 31-day scan window so expiry cannot turn it into a full-history table scan.

- **Incident map — system-admin pin corrections and removal**
- System admins (and the admin console) can **correct** a pin's address, nature, and location, or **remove** the plot entirely, from the incident popup on the map.
- **Correct Pin** opens an inline editor with **click-to-place** repositioning on the map; **Remove Pin** clears the map plot while keeping the call and its audio.
- Backed by `PUT`/`DELETE /api/incidents/pin/{callId}`, gated to system-admin users (or the admin token); corrections are stored with `manual`/`admin` status so they survive the map filter and are distinguishable from geocoded pins.

---

## Version 26.07.23 - Released July 20, 2026

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ export interface CallNature {
phrases?: string[];
enabled?: boolean;
order?: number;
expireMinutes?: number;
createdAt?: number;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<div class="admin-section-bar cn-page-header">
<p class="admin-section-hint">
Call natures are the incident categories shown on call cards. Add transcript phrases under each category for automatic matching.
A category can also force-expire its incidents off the live map after a set number of minutes, even when the viewer's selected time range would still include them.
</p>
<button type="button" mat-stroked-button (click)="addNew()">
<mat-icon>add</mat-icon>
Expand Down Expand Up @@ -44,6 +45,11 @@
<span class="cn-card-name">{{ nature.label || 'Unnamed' }}</span>
<span class="cn-count-badge">{{ (nature.phrases || []).length }}</span>
<span class="cn-disabled-badge" *ngIf="!nature.enabled">Disabled</span>
<span class="cn-expire-badge" *ngIf="nature.expireMinutes"
matTooltip="Incidents in this category are removed from the map {{ formatExpire(nature.expireMinutes) }} after dispatch">
<mat-icon>timer</mat-icon>
Expires {{ formatExpire(nature.expireMinutes) }}
</span>
</div>
<div class="cn-card-actions">
<button mat-icon-button (click)="startEdit(indexOfNature(nature))" matTooltip="Edit phrases">
Expand Down Expand Up @@ -77,6 +83,20 @@
<mat-slide-toggle formControlName="enabled">Enabled</mat-slide-toggle>
</div>

<div class="cn-edit-row cn-expire-row">
<mat-form-field appearance="outline" class="cn-field-expire">
<mat-label>Force expire (minutes)</mat-label>
<input matInput type="number" min="0" max="10080" step="5"
formControlName="expireMinutes" autocomplete="off">
<mat-hint>0 = never expire</mat-hint>
<mat-error>Whole number between 0 and 10080 (7 days)</mat-error>
</mat-form-field>
<p class="cn-expire-hint">
Removes this category's incidents from the live map after this many minutes, overriding the viewer's time range.
Disabled categories never expire incidents.
</p>
</div>

<div class="cn-edit-phrases">
<div class="cn-phrases-toolbar">
<span class="cn-section-label">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,25 @@
padding: 1px 6px;
}

.cn-expire-badge {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.4px;
color: #d9a441;
border: 1px solid rgba(217, 164, 65, 0.4);
border-radius: 4px;
padding: 1px 6px;

mat-icon {
font-size: 12px;
width: 12px;
height: 12px;
}
}

.cn-card-actions {
display: flex;
gap: 2px;
Expand Down Expand Up @@ -147,6 +166,22 @@
min-width: 240px;
}

.cn-expire-row {
align-items: flex-start;
}

.cn-field-expire {
width: 200px;
}

.cn-expire-hint {
flex: 1;
min-width: 220px;
margin: 10px 0 0;
color: #888;
font-size: 12px;
}

.cn-edit-phrases {
background: #141414;
border: 1px solid #2a2a2a;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class RdioScannerAdminCallNaturesComponent implements OnInit, OnDestroy {
phrases: nature.phrases ? [...nature.phrases] : [],
enabled: nature.enabled !== false,
order: nature.order ?? 0,
expireMinutes: nature.expireMinutes ?? 0,
createdAt: nature.createdAt,
};
}
Expand Down Expand Up @@ -100,6 +101,7 @@ export class RdioScannerAdminCallNaturesComponent implements OnInit, OnDestroy {
phrases: [nature.phrases || []],
enabled: [nature.enabled !== false],
order: [nature.order || 0],
expireMinutes: [nature.expireMinutes || 0, [Validators.min(0), Validators.max(10080), Validators.pattern(/^\d+$/)]],
});
this.editingPhrases = [...(nature.phrases || [])];
const phrasesControl = this.editingForm.get('phrases');
Expand Down Expand Up @@ -131,6 +133,7 @@ export class RdioScannerAdminCallNaturesComponent implements OnInit, OnDestroy {
...formValue,
label: (formValue.label || '').toUpperCase().trim(),
phrases: (formValue.phrases || []).map((p: string) => p.toUpperCase().trim()).filter((p: string) => p.length > 0),
expireMinutes: Math.max(0, Math.round(Number(formValue.expireMinutes) || 0)),
};

const ok = natureId
Expand Down Expand Up @@ -163,10 +166,24 @@ export class RdioScannerAdminCallNaturesComponent implements OnInit, OnDestroy {
phrases: [],
enabled: true,
order: 0,
expireMinutes: 0,
});
this.startEdit(0);
}

formatExpire(minutes: number | undefined): string {
const m = minutes || 0;
if (m <= 0) {
return '';
}
if (m < 60) {
return `${m} min`;
}
const hours = Math.floor(m / 60);
const rest = m % 60;
return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
}

addPhraseFromInput(): void {
if (!this.editingForm || !this.newPhraseText.trim()) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,5 +134,43 @@
</rdio-scanner-incident-map-sidebar>

<div id="tlr-incident-map-canvas" class="incident-map-canvas"></div>

<div class="pin-edit-panel" *ngIf="pinEdit">
<div class="pin-edit-title">
<mat-icon>edit_location_alt</mat-icon>
Correct Pin
</div>
<label class="pin-edit-field">
<span>Address</span>
<input class="toolbar-control" type="text" [(ngModel)]="pinEdit.address" autocomplete="off">
</label>
<label class="pin-edit-field">
<span>Nature</span>
<input class="toolbar-control" type="text" [(ngModel)]="pinEdit.nature" autocomplete="off">
</label>
<div class="pin-edit-coords">
<span class="pin-edit-coords__value" *ngIf="pinEdit.lat !== 0 || pinEdit.lon !== 0">
{{ pinEdit.lat.toFixed(5) }}, {{ pinEdit.lon.toFixed(5) }}
</span>
<span class="pin-edit-coords__value" *ngIf="pinEdit.lat === 0 && pinEdit.lon === 0">
Not placed
</span>
<button type="button"
class="toolbar-preset-btn"
[class.active]="movePinArmed"
(click)="toggleMovePin()">
{{ movePinArmed ? 'Click the map…' : 'Move on map' }}
</button>
</div>
<div class="pin-edit-actions">
<button type="button" class="toolbar-preset-btn" (click)="cancelPinEdit()">Cancel</button>
<button type="button"
class="toolbar-preset-btn pin-edit-save"
[disabled]="pinEditSaving"
(click)="savePinEdit()">
{{ pinEditSaving ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,82 @@ select.toolbar-control {
height: 100%;
overflow: hidden;
overscroll-behavior: none;
position: relative;
}

// System-admin pin correction panel (floats over the map canvas).
.pin-edit-panel {
position: absolute;
top: 12px;
right: 12px;
z-index: 1100;
width: 280px;
background: rgba(18, 18, 18, 0.96);
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 8px;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.55);
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}

.pin-edit-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #e0e0e0;

mat-icon {
font-size: 17px;
width: 17px;
height: 17px;
color: #d9a441;
}
}

.pin-edit-field {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 11px;
color: #999;

input {
width: 100%;
box-sizing: border-box;
}
}

.pin-edit-coords {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}

.pin-edit-coords__value {
font-size: 11.5px;
color: #bbb;
font-variant-numeric: tabular-nums;
}

.pin-edit-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}

.pin-edit-save {
background: #cc0000;
border-color: #cc0000;
color: #fff;

&[disabled] {
opacity: 0.6;
}
}

.incident-map-layout__sidebar {
Expand Down Expand Up @@ -332,6 +408,59 @@ select.toolbar-control {
}
}

// Admin actions inside the Leaflet popup (rendered outside Angular's view).
:host ::ng-deep .tlr-map-info__admin {
display: flex;
gap: 6px;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
}

:host ::ng-deep .tlr-map-info__admin-btn {
flex: 1;
font-size: 11px;
padding: 4px 8px;
border-radius: 4px;
border: 1px solid rgba(217, 164, 65, 0.55);
background: transparent;
color: #d9a441;
cursor: pointer;

&:hover {
background: rgba(217, 164, 65, 0.15);
}

&--danger {
border-color: rgba(204, 0, 0, 0.65);
color: #e06666;

&:hover {
background: rgba(204, 0, 0, 0.15);
}
}
}

// Crosshair cursor while placing a corrected pin location.
:host ::ng-deep .pin-move-armed {
cursor: crosshair !important;
}

// Temporary marker showing the corrected position before saving.
:host ::ng-deep .tlr-pin-edit-marker {
background: transparent !important;
border: 0 !important;
}

:host ::ng-deep .tlr-pin-edit-marker__dot {
width: 16px;
height: 16px;
border-radius: 50%;
background: rgba(217, 164, 65, 0.35);
border: 2px solid #d9a441;
box-shadow: 0 0 8px rgba(217, 164, 65, 0.8);
}

// Leaflet custom incident pins
:host ::ng-deep .tlr-incident-pin-icon {
background: transparent !important;
Expand Down
Loading