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
96 changes: 55 additions & 41 deletions app/src/api/flagshipReportStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import {
readOwnedItems,
} from '@/libs/flagship/ownedItems';
import {
ReportValidationSnapshot,
validationFromWire,
validationToWire,
} from '@/libs/flagship/reportValidation';
parseReportRecord,
parseReportRow,
readableReports,
stampReportRecord,
unwrapReport,
} from '@/libs/flagship/reportSchema';
import { ReportValidationSnapshot, validationToWire } from '@/libs/flagship/reportValidation';
import { RunReportProvision } from '@/libs/flagship/runReport';
import { clampStoreText } from '@/libs/flagship/storeText';

Expand Down Expand Up @@ -81,25 +84,9 @@ function newestFirst(a: FlagshipReportRecord, b: FlagshipReportRecord): number {
return b.createdAt.localeCompare(a.createdAt);
}

function fromMetadata(data: any): FlagshipReportRecord {
return {
id: data.id,
countryId: data.country_id,
apiReportId: data.api_report_id,
title: data.title,
sourceNote: data.source_note,
provisions: (data.provisions ?? []).map((p: any) => ({
path: p.path,
breadcrumb: p.breadcrumb,
unit: p.unit ?? null,
baselineValue: p.baseline_value,
value: p.value,
})),
year: data.year,
createdAt: data.created_at,
reformId: data.reform_id ?? null,
validation: validationFromWire(data.validation),
};
/** A central row as a record (see reportSchema); throws ReportSchemaError when unreadable. */
function fromMetadata(data: unknown): FlagshipReportRecord {
return unwrapReport(parseReportRow(data));
}

export class ApiFlagshipReportStore implements FlagshipReportStore {
Expand Down Expand Up @@ -158,7 +145,9 @@ export class ApiFlagshipReportStore implements FlagshipReportStore {
throwIfUnavailable(response);
throw new Error('Failed to load reports');
}
records.push(...((await response.json()) as any[]).map(fromMetadata));
// A row that can't be read is left out of the list, not fatal to it.
const rows: unknown = await response.json();
records.push(...readableReports((Array.isArray(rows) ? rows : []).map(parseReportRow)));
}
return records.sort(newestFirst);
}
Expand Down Expand Up @@ -215,7 +204,11 @@ const LOCAL_KEY = 'pe-flagship-reports';
* central store dropped user ids, which still carry a userId field.
*/
export class LocalStorageFlagshipReportStore implements FlagshipReportStore {
private read(): FlagshipReportRecord[] {
/**
* The entries as stored, of any schema version. Writes keep the ones
* this version can't read, so a record a newer version saved survives.
*/
private readStored(): unknown[] {
try {
const parsed = JSON.parse(localStorage.getItem(LOCAL_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
Expand All @@ -224,21 +217,43 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore {
}
}

private write(records: FlagshipReportRecord[]): void {
private write(entries: unknown[]): void {
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(records));
localStorage.setItem(LOCAL_KEY, JSON.stringify(entries));
} catch {
// Reports simply won't persist locally.
}
}

/** The readable records, in the current shape; an unreadable entry is left out. */
private read(): FlagshipReportRecord[] {
return readableReports(this.readStored().map(parseReportRecord));
}

/** The first entry with this field value, parsed; throws ReportSchemaError when unreadable. */
private findStored(
field: 'id' | 'apiReportId',
value: string
): { entries: unknown[]; index: number; record: FlagshipReportRecord } | null {
const entries = this.readStored();
const index = entries.findIndex(
(entry) => !!entry && typeof entry === 'object' && (entry as any)[field] === value
);
if (index === -1) {
return null;
}
return { entries, index, record: unwrapReport(parseReportRecord(entries[index])) };
}

async create(report: NewFlagshipReport): Promise<FlagshipReportRecord> {
const record: FlagshipReportRecord = {
...report,
id: `lfr-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
};
this.write([record, ...this.read()]);
const record = unwrapReport(
parseReportRecord({
...report,
id: `lfr-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`,
createdAt: new Date().toISOString(),
})
);
this.write([stampReportRecord(record), ...this.readStored()]);
return record;
}

Expand All @@ -249,25 +264,24 @@ export class LocalStorageFlagshipReportStore implements FlagshipReportStore {
}

async findById(id: string): Promise<FlagshipReportRecord | null> {
return this.read().find((record) => record.id === id) ?? null;
return this.findStored('id', id)?.record ?? null;
}

async findByApiReportId(apiReportId: string): Promise<FlagshipReportRecord | null> {
return this.read().find((record) => record.apiReportId === apiReportId) ?? null;
return this.findStored('apiReportId', apiReportId)?.record ?? null;
}

async saveValidation(
id: string,
snapshot: ReportValidationSnapshot
): Promise<FlagshipReportRecord> {
const records = this.read();
const index = records.findIndex((record) => record.id === id);
if (index === -1) {
const found = this.findStored('id', id);
if (!found) {
throw new Error('Report not found');
}
const updated = { ...records[index], validation: snapshot };
records[index] = updated;
this.write(records);
const updated = { ...found.record, validation: snapshot };
found.entries[found.index] = stampReportRecord(updated);
this.write(found.entries);
return updated;
}
}
Expand Down
113 changes: 113 additions & 0 deletions app/src/components/flagship/report/ReportDetailsError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { IconAlertTriangle } from '@tabler/icons-react';
import { Button } from '@/components/ui';
import { useAppNavigate } from '@/contexts/NavigationContext';
import { colors, spacing, typography } from '@/designTokens';
import { useCurrentCountry } from '@/hooks/useCurrentCountry';
import type { ReportSchemaError } from '@/libs/flagship/reportSchema';

/**
* A report whose stored details (title, source note, provisions) can't
* be read: saved by a newer version of the app, or damaged. The results
* can still open without them, rebuilt from the report's policy.
*/
export default function ReportDetailsError({
error,
onOpenWithout,
}: {
error: ReportSchemaError;
/** Opens the report without the stored details. */
onOpenWithout: () => void;
}) {
const nav = useAppNavigate();
const countryId = useCurrentCountry();
const newer = error.code === 'newer-version';

return (
<section
role="alert"
className="tw:flex tw:flex-col"
style={{
gap: spacing.md,
maxWidth: 640,
padding: spacing.xl,
border: `1px solid ${colors.border.light}`,
borderRadius: spacing.radius.feature,
background: colors.background.primary,
fontFamily: typography.fontFamily.primary,
}}
>
<div className="tw:flex tw:items-start" style={{ gap: spacing.md }}>
<span
aria-hidden
className="tw:inline-flex tw:shrink-0 tw:rounded-full"
style={{
padding: spacing.sm,
// The warning yellow, tinted.
background: `${colors.warning}33`,
color: colors.text.warning,
}}
>
<IconAlertTriangle size={20} />
</span>
<div className="tw:flex tw:flex-col" style={{ gap: spacing.xs }}>
<h2
style={{
margin: 0,
fontSize: typography.fontSize.lg,
fontWeight: typography.fontWeight.semibold,
color: colors.text.primary,
}}
>
This report's saved details can't be read
</h2>
<p
style={{
margin: 0,
fontSize: typography.fontSize.sm,
lineHeight: typography.lineHeight.relaxed,
color: colors.text.secondary,
}}
>
{newer
? 'A newer version of PolicyEngine saved them. Reload to update.'
: 'They are damaged in this browser.'}
</p>
</div>
</div>

<div className="tw:flex tw:flex-wrap" style={{ gap: spacing.sm }}>
{newer && <Button onClick={() => window.location.reload()}>Reload</Button>}
<Button variant={newer ? 'outline' : 'default'} onClick={onOpenWithout}>
Open without saved details
</Button>
<Button variant="outline" onClick={() => nav.push(`/${countryId}/build`)}>
Back to Build
</Button>
</div>

<details>
<summary
className="tw:cursor-pointer"
style={{ fontSize: typography.fontSize.xs, color: colors.text.tertiary }}
>
Technical details
</summary>
<pre
style={{
margin: `${spacing.sm} 0 0`,
padding: spacing.sm,
borderRadius: spacing.radius.element,
background: colors.gray[50],
fontSize: typography.fontSize.xs,
fontFamily: typography.fontFamily.mono,
color: colors.text.secondary,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{error.message}
</pre>
</details>
</section>
);
}
35 changes: 35 additions & 0 deletions app/src/hooks/useUnreadableReportMeta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useMemo, useState } from 'react';
import type { ReportSchemaError } from '@/libs/flagship/reportSchema';
import { readReportMetaResult, removeReportMeta } from '@/libs/flagship/runReport';

export interface UnreadableReportMeta {
/** Why the stored provenance can't be read; null when it can, none is stored, or it is set aside. */
error: ReportSchemaError | null;
/** Opens the report without it: the page rebuilds the provenance from the policy. */
setAside: () => void;
}

/**
* The provenance this browser stored when it ran the report, when the
* report schema can't read it. `readReportMeta` answers null for it, so
* once set aside the page carries on as for a report with none stored.
*
* Setting aside damaged provenance removes it, since no version of the
* app can read it, so the report opens without asking next time.
* Provenance a newer version saved stays stored for that version.
*/
export function useUnreadableReportMeta(userReportId: string): UnreadableReportMeta {
const [setAsideId, setSetAsideId] = useState<string | null>(null);
// Read once per report: the page reads the provenance on every render.
const error = useMemo(() => {
const result = readReportMetaResult(userReportId);
return result && !result.ok ? result.error : null;
}, [userReportId]);
const setAside = () => {
if (error && error.code !== 'newer-version') {
removeReportMeta(userReportId);
}
setSetAsideId(userReportId);
};
return { error: setAsideId === userReportId ? null : error, setAside };
}
Loading
Loading