8 Commits

Author SHA1 Message Date
7a6bf84eb5 trips) tune dashboard map offsets
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Has been cancelled
2026-02-24 22:40:17 +00:00
c12b21ae8a trips) adjust dashboard map alignment
Some checks failed
PR Checks / lint-test-and-docker-build (pull_request) Has been cancelled
2026-02-24 22:35:13 +00:00
94fbd017cb trips) refresh dashboard header and map
All checks were successful
PR Checks / lint-test-and-docker-build (pull_request) Successful in 2m9s
2026-02-24 22:19:31 +00:00
ebc8f80bb5 trips) add dashboard overview (#54)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m14s
Reviewed-on: #54
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-24 21:23:54 +00:00
4aabf47108 trip) add day/week trip views (#52)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m19s
Reviewed-on: #52
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-24 20:17:26 +00:00
2003a1aada Add LATAM airline icons (#50)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m22s
Reviewed-on: #50
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-24 17:58:23 +00:00
ae39f7961a trip) add timeline view to trip page (#48)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m21s
Co-authored-by: Shaun Campbell <shaun@campbellwireless.net>
Reviewed-on: #48
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-23 03:17:44 +00:00
d43889c7b0 trips) add low-priority e2e smoke coverage (#46)
All checks were successful
Build and Push Image / docker-build-and-push (push) Successful in 2m28s
Reviewed-on: #46
Co-authored-by: AI Agent <ai-agent@campbellwireless.net>
Co-committed-by: AI Agent <ai-agent@campbellwireless.net>
2026-02-23 00:48:21 +00:00
28 changed files with 4593 additions and 417 deletions

View File

@@ -13,7 +13,7 @@ LOCAL_AUTH_ARGON2_PARALLELISM=1
ADMIN_USER_IDS=e2e_admin
# Separate test database — never touches trips.db
DATABASE_URL=file:trips.test.db
DATABASE_URL=file:/tmp/trips.test.db
# Synology OIDC — not exercised in E2E tests but must be present to avoid startup errors
SYNOLOGY_ISSUER=https://cloud.campbellwireless.net/auth/webman/sso

78
e2e/dashboard.test.ts Normal file
View File

@@ -0,0 +1,78 @@
import { test, expect, type Page } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
import { createTrip, openAddToTripMenuItem, uniqueSuffix } from './helpers/trip.js';
function formatDate(offsetDays: number): string {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
async function addDestination(page: Page, cityQuery: string, arrivalDate?: string): Promise<void> {
await openAddToTripMenuItem(page, 'Destinations');
const dialog = page.getByRole('dialog', { name: 'Add destination' });
await expect(dialog).toBeVisible();
await dialog.getByLabel(/City/).fill(cityQuery);
const cityOption = dialog.locator('ul button').filter({ hasText: cityQuery }).first();
await expect(cityOption).toBeVisible();
await cityOption.click();
if (arrivalDate) {
await dialog.getByLabel('Arrival').fill(arrivalDate);
}
await dialog.getByRole('button', { name: 'Add to trip' }).click();
await expect(dialog).toBeHidden();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('dashboard surfaces map stops, stats, and trip status', async ({ page }) => {
const screenshotPath = test.info().outputPath('dashboard.png');
const suffix = uniqueSuffix();
const pastStart = formatDate(-18);
const pastEnd = formatDate(-15);
const currentStart = formatDate(-2);
const currentEnd = formatDate(4);
const futureStart = formatDate(28);
const futureEnd = formatDate(34);
const pastTripName = `E2E Past Trip ${suffix}`;
const currentTripName = `E2E Current Trip ${suffix}`;
const futureTripName = `E2E Future Trip ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await createTrip(page, { name: pastTripName, startDate: pastStart, endDate: pastEnd });
await addDestination(page, 'London', pastStart);
await createTrip(page, { name: currentTripName, startDate: currentStart, endDate: currentEnd });
await addDestination(page, 'New York', currentStart);
await createTrip(page, { name: futureTripName, startDate: futureStart, endDate: futureEnd });
await addDestination(page, 'Tokyo', futureStart);
await page.goto('/trips/dashboard');
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
const map = page.getByTestId('trip-map');
await expect(map).toBeVisible();
await expect(map.locator('[data-testid="map-stop"][data-status="past"]')).toHaveCount(1);
await expect(map.locator('[data-testid="map-stop"][data-status="current"]')).toHaveCount(1);
await expect(map.locator('[data-testid="map-stop"][data-status="future"]')).toHaveCount(1);
await expect(page.getByRole('heading', { name: 'Trips in progress' })).toBeVisible();
await expect(page.getByText(currentTripName)).toBeVisible();
await expect(page.getByRole('heading', { name: 'Trips being planned' })).toBeVisible();
await expect(page.getByText(futureTripName)).toBeVisible();
await expect(page.getByTestId('stat-past-trips')).toHaveText('1');
await expect(page.getByTestId('stat-countries')).toHaveText('1');
await page.screenshot({ path: screenshotPath, fullPage: true });
});

View File

@@ -0,0 +1,9 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
test('evidence workflow @evidence login to dashboard', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await expect(page).toHaveURL(/\/trips\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

View File

@@ -12,21 +12,27 @@ export function uniqueSuffix(): string {
export async function createTrip(
page: Page,
values: { name: string; startDate?: string; description?: string }
values: { name: string; startDate?: string; endDate?: string; description?: string }
): Promise<{ tripUrl: string; tripId: string }> {
await page.goto(NEW_TRIP_URL);
await expect(page.getByRole('heading', { name: 'Plan New Trip' })).toBeVisible();
const form = page.locator('main form').first();
if (values.startDate) {
await page.getByLabel('Start date').fill(values.startDate);
await form.getByLabel('Start date').fill(values.startDate);
} else {
await page.getByRole('checkbox', { name: "I don't know yet" }).first().check();
await form.getByRole('checkbox', { name: "I don't know yet" }).first().check();
}
if (values.endDate) {
await form.getByLabel('End date').fill(values.endDate);
}
if (values.description) {
await page.getByLabel('Description').fill(values.description);
await form.getByLabel('Description').fill(values.description);
}
await page.locator('input[name="name"]').fill(values.name);
await page.getByRole('button', { name: 'Save' }).click();
await expect(page).toHaveURL(/\/trips\/trips\/(?!new$)[^/?#]+$/, { timeout: 15_000 });
await form.getByLabel('Trip name *').fill(values.name);
await form.evaluate((node) => {
(node as HTMLFormElement).requestSubmit();
});
await expect(page).toHaveURL(/\/trips\/trips\/(?!new$)[^/?#]+$/, { timeout: 30_000 });
const tripUrl = page.url();
const tripId = tripUrl.split('/').pop() ?? '';
return { tripUrl, tripId };

View File

@@ -10,7 +10,17 @@ import { TEST_USERS } from './test-users.js';
dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true });
const DB_PATH = resolve(process.cwd(), 'trips.test.db');
function resolveDatabasePath(): string {
const rawUrl = process.env.DATABASE_URL;
if (rawUrl && rawUrl.startsWith('file:')) {
const rawPath = rawUrl.slice('file:'.length);
if (!rawPath) return resolve(process.cwd(), 'trips.test.db');
return rawPath.startsWith('/') ? rawPath : resolve(process.cwd(), rawPath);
}
return resolve(process.cwd(), 'trips.test.db');
}
const DB_PATH = resolveDatabasePath();
// Delete any existing test DB so we start clean each run
for (const ext of ['', '-shm', '-wal']) {
@@ -20,6 +30,7 @@ for (const ext of ['', '-shm', '-wal']) {
}
}
console.log(`[e2e] Using test database at ${DB_PATH}`);
const db = new BunSqlite(DB_PATH);
db.exec('PRAGMA foreign_keys = ON');
@@ -40,6 +51,7 @@ const dbWrapper = {
}
};
console.log('[e2e] Running migrations...');
const { runMigrations } = await import('../../src/lib/server/db/migrations.js');
runMigrations(dbWrapper);
console.log('[e2e] Migrations complete.');
@@ -53,6 +65,7 @@ async function hashPassword(password: string): Promise<string> {
});
}
console.log('[e2e] Creating test users...');
const regularId = randomUUID();
dbWrapper.run(
`INSERT INTO users (id, username, full_name, email, auth_source) VALUES (?, ?, ?, ?, ?)`,

View File

@@ -0,0 +1,79 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser } from './helpers/auth.js';
import { uniqueSuffix } from './helpers/trip.js';
const DASHBOARD_URL = '/trips/dashboard';
const ADMIN_USERS_URL = '/trips/admin/users';
async function expectDashboardLoaded(page: Parameters<typeof loginAsLocalUser>[0]): Promise<void> {
await expect(page).toHaveURL(/\/trips\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('regular user smoke', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await expectDashboardLoaded(page);
await page.goto(ADMIN_USERS_URL);
await expect(page).toHaveURL(/\/trips\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});
test('admin user smoke', async ({ page }) => {
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await expectDashboardLoaded(page);
await page.goto(ADMIN_USERS_URL);
await expect(page).toHaveURL(/\/trips\/admin\/users/);
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
});
test('disposable local user smoke', async ({ page }) => {
const suffix = uniqueSuffix();
const username = `e2e_disposable_${suffix}`;
const fullName = `E2E Disposable ${suffix}`;
const email = `e2e_disposable_${suffix}@test.local`;
const password = `TempPass-${suffix}-1234`;
await loginAsLocalUser(page, TEST_USERS.admin.username, TEST_USERS.admin.password);
await page.goto(ADMIN_USERS_URL);
await expect(page.getByRole('heading', { name: 'Users' })).toBeVisible();
await page.getByRole('button', { name: 'Add User' }).click();
const addDialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: 'Add user' }) })
.first();
await expect(addDialog).toBeVisible();
await addDialog.locator('input[type="text"]').nth(0).fill(username);
await addDialog.locator('input[type="text"]').nth(1).fill(fullName);
await addDialog.getByPlaceholder('name@example.com').fill(email);
await addDialog.locator('input[type="password"]').nth(0).fill(password);
await addDialog.locator('input[type="password"]').nth(1).fill(password);
const createResponse = page.waitForResponse((response) => {
return (
response.url().includes('/admin/api/users') &&
response.request().method() === 'POST' &&
response.ok()
);
});
await addDialog.getByRole('button', { name: 'Create user' }).click();
await createResponse;
await expect(page.getByRole('cell', { name: username, exact: true })).toBeVisible();
await page.context().clearCookies();
await loginAsLocalUser(page, username, password);
await expectDashboardLoaded(page);
await page.goto(ADMIN_USERS_URL);
await expect(page).toHaveURL(/\/trips\/dashboard/);
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
});

View File

@@ -0,0 +1,70 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser, ensureSelfProfile } from './helpers/auth.js';
import { createTrip, openAddToTripMenuItem, uniqueSuffix } from './helpers/trip.js';
function formatDate(offsetDays: number): string {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
async function addActivity(
page: import('@playwright/test').Page,
values: { name: string; startDate: string; startTime: string }
) {
await openAddToTripMenuItem(page, 'Attractions & Activities');
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: /Attraction & activity/i }) })
.first();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
await dialog.getByLabel('Start date').fill(values.startDate);
await dialog.getByLabel('Start time', { exact: true }).fill(values.startTime);
await dialog.getByRole('button', { name: 'Add' }).click();
await expect(dialog).toBeHidden();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('day and week views filter itinerary items', async ({ page }) => {
const suffix = uniqueSuffix();
const startDate = formatDate(0);
const endDate = formatDate(9);
const dayOneActivity = `Museum visit ${suffix}`;
const dayEightActivity = `Harbor walk ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
await createTrip(page, {
name: `E2E Date Views Trip ${suffix}`,
startDate,
endDate,
description: 'Date view coverage'
});
await addActivity(page, { name: dayOneActivity, startDate, startTime: '09:00' });
await addActivity(page, { name: dayEightActivity, startDate: formatDate(7), startTime: '11:00' });
await page.reload();
await expect(page.getByText(dayOneActivity)).toBeVisible();
await page.getByRole('button', { name: 'Day' }).click();
await expect(page.getByText('Day 1')).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeHidden();
await page.getByRole('button', { name: 'Week' }).click();
await expect(page.getByText('Week 1')).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeHidden();
await page.getByRole('button', { name: 'Next week' }).click();
await expect(page.getByText('Week 2')).toBeVisible();
await expect(page.getByText(dayEightActivity)).toBeVisible();
await expect(page.getByText(dayOneActivity)).toBeHidden();
});

131
e2e/trip-timeline.test.ts Normal file
View File

@@ -0,0 +1,131 @@
import { test, expect } from '@playwright/test';
import { TEST_USERS } from './setup/test-users.js';
import { loginAsLocalUser, ensureSelfProfile } from './helpers/auth.js';
import { addPackingList, createTrip, openAddToTripMenuItem, uniqueSuffix } from './helpers/trip.js';
function formatDate(offsetDays: number): string {
const date = new Date();
date.setDate(date.getDate() + offsetDays);
return date.toISOString().slice(0, 10);
}
async function addDestination(
page: import('@playwright/test').Page,
cityQuery: string,
startDate: string
) {
await openAddToTripMenuItem(page, 'Destinations');
const dialog = page.getByRole('dialog', { name: 'Add destination' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('City', { exact: false }).fill(cityQuery);
const cityOption = dialog.locator('ul button').filter({ hasText: cityQuery }).first();
await expect(cityOption).toBeVisible();
await cityOption.click();
await dialog.getByLabel('Arrival').fill(startDate);
await dialog.getByRole('button', { name: 'Add to trip' }).click();
await expect(dialog).toBeHidden();
}
async function addActivity(
page: import('@playwright/test').Page,
values: { name: string; startDate: string; startTime: string }
) {
await openAddToTripMenuItem(page, 'Attractions & Activities');
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.getByRole('heading', { name: /Attraction & activity/i }) })
.first();
await expect(dialog).toBeVisible();
await dialog.getByLabel('Name', { exact: false }).fill(values.name);
await dialog.getByLabel('Start date').fill(values.startDate);
await dialog.getByLabel('Start time', { exact: true }).fill(values.startTime);
await dialog.getByRole('button', { name: 'Add' }).click();
await expect(dialog).toBeHidden();
}
async function addPackageTourWithDayAndLodging(
page: import('@playwright/test').Page,
values: { operatorName: string; tourName: string; startDate: string; dayTitle: string }
) {
await openAddToTripMenuItem(page, 'Package Tours');
const dialog = page.getByRole('dialog', { name: 'Add package tour' });
await expect(dialog).toBeVisible();
await dialog.getByLabel('Tour operator', { exact: false }).fill(values.operatorName);
await dialog.getByLabel('Tour name', { exact: false }).fill(values.tourName);
await dialog.locator('input[name="start_date"]').fill(values.startDate);
await dialog.getByRole('button', { name: 'Add tour' }).click();
await expect(dialog).toBeHidden();
await expect(page.getByText(values.operatorName)).toBeVisible();
await page.getByRole('button', { name: 'Add day' }).click();
const addDayForm = page.locator('form[action="?/addTourDay"]');
await expect(addDayForm).toBeVisible();
await addDayForm.getByPlaceholder(/Day title/i).fill(values.dayTitle);
await addDayForm.getByRole('button', { name: 'Add day' }).click();
const dayRow = page
.locator('div.mb-2.overflow-hidden.rounded-lg.border.border-gray-100')
.filter({ hasText: `Day 1` })
.filter({ hasText: values.dayTitle })
.first();
await expect(dayRow).toBeVisible();
}
test.beforeEach(async ({ context }) => {
await context.clearCookies();
});
test('timeline view shows scheduled and unscheduled plans with tour days', async ({ page }) => {
const suffix = uniqueSuffix();
const startDate = formatDate(10);
const activityDate = formatDate(11);
const destinationDate = formatDate(12);
const tourStartDate = formatDate(13);
const tripName = `E2E Timeline Trip ${suffix}`;
const activityName = `Waterfall hike ${suffix}`;
const destinationName = 'Tokyo';
const packingListName = `Timeline essentials ${suffix}`;
const tourOperator = `Trailblazer ${suffix}`;
const tourName = `Jungle Trek ${suffix}`;
const tourDayTitle = `Rainforest ${suffix}`;
await loginAsLocalUser(page, TEST_USERS.regular.username, TEST_USERS.regular.password);
await ensureSelfProfile(page);
await createTrip(page, {
name: tripName,
startDate,
description: 'Timeline view coverage'
});
await addDestination(page, destinationName, destinationDate);
await addActivity(page, { name: activityName, startDate: activityDate, startTime: '09:00' });
await addPackingList(page, { name: packingListName, items: ['Passport', 'Sunscreen'] });
await addPackageTourWithDayAndLodging(page, {
operatorName: tourOperator,
tourName,
startDate: tourStartDate,
dayTitle: tourDayTitle
});
await page.getByRole('button', { name: 'Timeline' }).click();
await expect(page.getByRole('button', { name: 'Timeline' })).toHaveAttribute(
'aria-pressed',
'true'
);
await expect(page.getByText(activityName)).toBeVisible();
await expect(page.getByText(destinationName, { exact: false })).toBeVisible();
await expect(page.getByText('09:00').first()).toBeVisible();
await expect(page.getByText(`Day 1 — ${tourDayTitle}`)).toBeVisible();
await expect(page.getByText('Unscheduled')).toBeVisible();
await expect(page.getByText(packingListName)).toBeVisible();
});

View File

@@ -5,6 +5,11 @@ import { resolve } from 'path';
// Load .env.test so both this process and the webServer child process see the values.
dotenvConfig({ path: resolve(process.cwd(), '.env.test'), override: true });
const chromiumExecutablePath = process.env.PLAYWRIGHT_CHROMIUM_PATH;
const launchOptions = chromiumExecutablePath
? { executablePath: chromiumExecutablePath }
: undefined;
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
@@ -16,7 +21,8 @@ export default defineConfig({
use: {
baseURL: 'http://127.0.0.1:5173',
trace: process.env.PW_TRACE_MODE ?? 'on-first-retry',
video: process.env.PW_VIDEO_MODE ?? 'off'
video: process.env.PW_VIDEO_MODE ?? 'off',
...(launchOptions ? { launchOptions } : {})
},
projects: [

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import { base } from '$app/paths';
interface Props {
operatorName: string;
tourName?: string | null;
highlightColor?: string | null;
size?: 'sm' | 'md';
showName?: boolean;
}
let {
operatorName,
tourName = null,
highlightColor = null,
size = 'sm',
showName = true
}: Props = $props();
function logoSlug(name: string): string {
return name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_|_$/g, '');
}
let logoVariant = $state<'square' | 'wide' | 'none'>('square');
const squareLogoUrl = $derived(
`${base}/package-tour-logos/${logoSlug(operatorName)}_square.svg`
);
const wideLogoUrl = $derived(
`${base}/package-tour-logos/${logoSlug(operatorName)}_wide.svg`
);
const logoUrl = $derived(
logoVariant === 'square' ? squareLogoUrl : logoVariant === 'wide' ? wideLogoUrl : null
);
const sizeClass = $derived(size === 'md' ? 'h-6 w-6' : 'h-5 w-5');
const textSizeClass = $derived(size === 'md' ? 'text-xs' : 'text-[10px]');
function handleLogoError() {
logoVariant = logoVariant === 'square' ? 'wide' : 'none';
}
</script>
<div class="flex items-center gap-2 text-xs text-gray-500">
{#if logoUrl}
<img
src={logoUrl}
alt={operatorName}
class="{sizeClass} rounded-md object-contain"
on:error={handleLogoError}
/>
{:else}
<span
class="{sizeClass} {textSizeClass} flex items-center justify-center rounded-md border border-gray-200 font-semibold"
style="background-color: {highlightColor ?? '#F3F4F6'}; color: {highlightColor ? '#111827' : '#6B7280'}"
>
{operatorName.slice(0, 1).toUpperCase()}
</span>
{/if}
{#if showName}
<span class="truncate">{operatorName}{tourName ? `: ${tourName}` : ''}</span>
{/if}
</div>

View File

@@ -0,0 +1,109 @@
<script lang="ts">
import TourBadge from '$lib/components/TourBadge.svelte';
import type { Plan } from '$lib/server/plans.js';
import type { PackageTour } from '$lib/server/package-tours.js';
interface Props {
plan: Plan;
tour: PackageTour & { highlight_color: string | null };
onEdit?: () => void;
onDelete?: () => void;
}
let { plan, tour, onEdit, onDelete }: Props = $props();
function formatDate(d: string | null): string {
if (!d) return '';
return new Date(d + 'T00:00:00').toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
function formatTime(t: string | null): string {
if (!t) return '';
return t.slice(0, 5);
}
const statusConfig = {
idea: { label: 'Idea', class: 'bg-gray-100 text-gray-600' },
tentative: { label: 'Tentative', class: 'bg-yellow-100 text-yellow-800' },
confirmed: { label: 'Confirmed', class: 'bg-green-100 text-green-800' }
};
const hasStart = $derived(tour.start_date || tour.start_time);
const hasEnd = $derived(tour.end_date || tour.end_time);
</script>
<div
class="rounded-xl border-2 bg-white p-4 shadow-sm"
style="border-color: {tour.highlight_color ?? '#E5E7EB'}"
>
<div class="flex items-start justify-between gap-3">
<TourBadge
operatorName={tour.operator_name}
tourName={tour.tour_name}
highlightColor={tour.highlight_color}
size="md"
/>
<div class="flex shrink-0 items-center gap-1">
<span
class="rounded-full px-2.5 py-0.5 text-xs font-medium {statusConfig[plan.status].class}"
>
{statusConfig[plan.status].label}
</span>
{#if onEdit}
<button
type="button"
onclick={onEdit}
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="Edit tour"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
{/if}
{#if onDelete}
<button
type="button"
onclick={onDelete}
class="rounded-md p-1.5 text-gray-400 hover:bg-gray-100 hover:text-red-600"
aria-label="Remove tour"
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
{/if}
</div>
</div>
{#if hasStart || hasEnd}
<div class="mt-3 grid grid-cols-2 gap-4 border-t border-gray-100 pt-3">
<div>
<p class="text-xs font-medium tracking-wide text-gray-400 uppercase">Start</p>
<p class="mt-0.5 text-sm text-gray-900">
{#if tour.start_date}
{formatDate(tour.start_date)} {formatTime(tour.start_time)}
{:else}
{formatTime(tour.start_time)}
{/if}
</p>
</div>
<div>
<p class="text-xs font-medium tracking-wide text-gray-400 uppercase">End</p>
<p class="mt-0.5 text-sm text-gray-900">
{#if tour.end_date}
{formatDate(tour.end_date)} {formatTime(tour.end_time)}
{:else}
{formatTime(tour.end_time)}
{/if}
</p>
</div>
</div>
{/if}
</div>

View File

@@ -4,28 +4,36 @@
import type { Trip } from '$lib/server/trips.js';
interface Props {
trips: Trip[];
title: string;
emptyMessage: string;
trips?: Trip[];
title?: string;
emptyMessage?: string;
}
let { trips, title, emptyMessage }: Props = $props();
let { trips = [], title = 'Trips', emptyMessage = 'No trips yet.' }: Props = $props();
const tripList = $derived(trips ?? []);
let view = $state<'tile' | 'table'>('tile');
</script>
<div class="flex items-center justify-between mb-6">
<div class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900">{title}</h1>
{#if trips.length > 0}
{#if tripList.length > 0}
<div class="flex items-center gap-1 rounded-md border border-gray-200 bg-gray-50 p-1">
<button
onclick={() => view = 'tile'}
onclick={() => (view = 'tile')}
title="Tile view"
class="rounded p-1.5 transition-colors"
style={view === 'tile' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
>
<!-- Grid icon -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'tile' ? '#111827' : '#9ca3af'} stroke-width="2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={view === 'tile' ? '#111827' : '#9ca3af'}
stroke-width="2"
>
<rect x="3" y="3" width="7" height="7" rx="1" />
<rect x="14" y="3" width="7" height="7" rx="1" />
<rect x="3" y="14" width="7" height="7" rx="1" />
@@ -33,13 +41,20 @@
</svg>
</button>
<button
onclick={() => view = 'table'}
onclick={() => (view = 'table')}
title="Table view"
class="rounded p-1.5 transition-colors"
style={view === 'table' ? 'background:#fff; box-shadow:0 1px 2px rgba(0,0,0,0.08);' : ''}
>
<!-- List icon -->
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke={view === 'table' ? '#111827' : '#9ca3af'} stroke-width="2">
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke={view === 'table' ? '#111827' : '#9ca3af'}
stroke-width="2"
>
<line x1="3" y1="6" x2="21" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="21" y2="18" />
@@ -49,14 +64,14 @@
{/if}
</div>
{#if trips.length === 0}
{#if tripList.length === 0}
<p class="text-gray-500">{emptyMessage}</p>
{:else if view === 'tile'}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each trips as trip}
{#each tripList as trip}
<TripTile {trip} />
{/each}
</div>
{:else}
<TripTable {trips} />
<TripTable trips={tripList} />
{/if}

View File

@@ -0,0 +1,34 @@
import { describe, it, expect } from 'vitest';
import { addDaysToDate, buildDateSequence, buildWeekRanges } from './date-views.js';
describe('date-views', () => {
it('adds days to a date string', () => {
expect(addDaysToDate('2026-04-01', 1)).toBe('2026-04-02');
expect(addDaysToDate('2026-04-01', 7)).toBe('2026-04-08');
});
it('builds an inclusive date sequence', () => {
const dates = buildDateSequence('2026-04-01', '2026-04-03');
expect(dates).toEqual(['2026-04-01', '2026-04-02', '2026-04-03']);
});
it('returns empty sequence when end is before start', () => {
expect(buildDateSequence('2026-04-03', '2026-04-01')).toEqual([]);
});
it('chunks weeks into seven-day ranges', () => {
const dates = buildDateSequence('2026-04-01', '2026-04-10');
const ranges = buildWeekRanges(dates);
expect(ranges).toHaveLength(2);
expect(ranges[0]).toEqual({
start: '2026-04-01',
end: '2026-04-07',
weekNumber: 1
});
expect(ranges[1]).toEqual({
start: '2026-04-08',
end: '2026-04-10',
weekNumber: 2
});
});
});

33
src/lib/date-views.ts Normal file
View File

@@ -0,0 +1,33 @@
export function addDaysToDate(date: string, offset: number): string {
const [year, month, day] = date.split('-').map(Number);
const ts = Date.UTC(year, month - 1, day + offset);
return new Date(ts).toISOString().slice(0, 10);
}
export function buildDateSequence(start: string, end: string): string[] {
if (!start || !end) return [];
if (start > end) return [];
const dates: string[] = [];
let current = start;
while (current <= end) {
dates.push(current);
current = addDaysToDate(current, 1);
}
return dates;
}
export function buildWeekRanges(
dates: string[]
): Array<{ start: string; end: string; weekNumber: number }> {
const ranges: Array<{ start: string; end: string; weekNumber: number }> = [];
for (let i = 0; i < dates.length; i += 7) {
const slice = dates.slice(i, i + 7);
if (slice.length === 0) continue;
ranges.push({
start: slice[0],
end: slice[slice.length - 1],
weekNumber: Math.floor(i / 7) + 1
});
}
return ranges;
}

192
src/lib/server/dashboard.ts Normal file
View File

@@ -0,0 +1,192 @@
import { db } from './db/index.js';
import type { Trip } from './trips.js';
export type TripStatus = 'past' | 'in_progress' | 'planned';
export type MapStopStatus = 'past' | 'current' | 'future';
export interface DashboardTripSummary extends Trip {
status: TripStatus;
planCount: number;
}
export interface DashboardMapStop {
tripId: string;
tripName: string;
label: string;
country: string | null;
countryCode: string;
lat: number;
lon: number;
status: MapStopStatus;
}
export interface DashboardStats {
pastTrips: number;
daysAway: number;
countriesVisited: number;
citiesVisited: number;
topCountries: Array<{ country: string; countryCode: string; count: number }>;
}
export interface DashboardData {
trips: DashboardTripSummary[];
inProgressTrips: DashboardTripSummary[];
plannedTrips: DashboardTripSummary[];
mapStops: DashboardMapStop[];
stats: DashboardStats;
}
function isValidDate(value: string | null): value is string {
return Boolean(value && /^\d{4}-\d{2}-\d{2}$/.test(value));
}
function toDate(value: string | null): Date | null {
if (!isValidDate(value)) return null;
const parsed = new Date(`${value}T00:00:00Z`);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function dayDiffInclusive(start: string | null, end: string | null): number {
const startDate = toDate(start);
const endDate = toDate(end);
if (!startDate || !endDate) return 0;
const diffMs = endDate.getTime() - startDate.getTime();
if (Number.isNaN(diffMs)) return 0;
return Math.max(0, Math.round(diffMs / 86400000) + 1);
}
function getTripStatus(trip: Trip, today: string): TripStatus {
if (trip.end_date && trip.end_date < today) return 'past';
if (trip.start_date && trip.start_date <= today) return 'in_progress';
return 'planned';
}
export function getDashboardData(userId: string): DashboardData {
const today = new Date().toISOString().slice(0, 10);
const trips = db.all<Trip>(
`SELECT * FROM trips WHERE user_id = ? ORDER BY COALESCE(start_date, created_at) ASC`,
[userId]
);
const planCounts = db.all<{ trip_id: string; count: number }>(
`SELECT trip_id, COUNT(*) as count FROM plans WHERE user_id = ? GROUP BY trip_id`,
[userId]
);
const planCountByTripId = new Map(planCounts.map((row) => [row.trip_id, row.count]));
const summaryTrips: DashboardTripSummary[] = trips.map((trip) => ({
...trip,
status: getTripStatus(trip, today),
planCount: planCountByTripId.get(trip.id) ?? 0
}));
const inProgressTrips = summaryTrips.filter((trip) => trip.status === 'in_progress');
const plannedTrips = summaryTrips.filter((trip) => trip.status === 'planned');
const pastTrips = summaryTrips.filter((trip) => trip.status === 'past');
const mapStopRows = db.all<{
trip_id: string;
trip_name: string;
label: string | null;
country: string | null;
country_code: string | null;
lat: number | null;
lon: number | null;
}>(
`SELECT
p.trip_id as trip_id,
MAX(t.name) as trip_name,
COALESCE(MAX(p.city_name), MAX(p.country)) as label,
MAX(p.country) as country,
p.country_code as country_code,
c.lat as lat,
c.lon as lon
FROM plans p
JOIN trips t ON t.id = p.trip_id
JOIN (
SELECT country_code, AVG(latitude) as lat, AVG(longitude) as lon
FROM airports
WHERE latitude IS NOT NULL AND longitude IS NOT NULL
GROUP BY country_code
) c ON c.country_code = p.country_code
WHERE p.user_id = ?
AND p.type = 'destination'
AND p.country_code IS NOT NULL
AND TRIM(p.country_code) != ''
GROUP BY p.trip_id, p.country_code
ORDER BY MIN(p.created_at) ASC`,
[userId]
);
const statusByTripId = new Map(summaryTrips.map((trip) => [trip.id, trip.status]));
const mapStops: DashboardMapStop[] = mapStopRows
.filter((row) => row.country_code && row.lat !== null && row.lon !== null)
.map((row) => {
const status = statusByTripId.get(row.trip_id) ?? 'planned';
const mapStatus: MapStopStatus =
status === 'past' ? 'past' : status === 'in_progress' ? 'current' : 'future';
return {
tripId: row.trip_id,
tripName: row.trip_name ?? 'Trip',
label: row.label ?? row.country ?? 'Destination',
country: row.country,
countryCode: row.country_code ?? 'XX',
lat: row.lat ?? 0,
lon: row.lon ?? 0,
status: mapStatus
};
});
const destinationRows = db.all<{
country: string | null;
country_code: string | null;
city_name: string | null;
}>(
`SELECT p.country, p.country_code, p.city_name
FROM plans p
JOIN trips t ON t.id = p.trip_id
WHERE p.user_id = ?
AND t.user_id = ?
AND t.end_date IS NOT NULL
AND t.end_date < ?
AND p.type = 'destination'`,
[userId, userId, today]
);
const countryCounts = new Map<string, { country: string; countryCode: string; count: number }>();
const cities = new Set<string>();
for (const row of destinationRows) {
if (row.city_name) cities.add(row.city_name);
if (!row.country_code) continue;
const existing = countryCounts.get(row.country_code) ?? {
country: row.country ?? row.country_code,
countryCode: row.country_code,
count: 0
};
existing.count += 1;
countryCounts.set(row.country_code, existing);
}
const topCountries = Array.from(countryCounts.values())
.sort((a, b) => b.count - a.count || a.country.localeCompare(b.country))
.slice(0, 5);
const daysAway = pastTrips.reduce(
(total, trip) => total + dayDiffInclusive(trip.start_date, trip.end_date),
0
);
return {
trips: summaryTrips,
inProgressTrips,
plannedTrips,
mapStops,
stats: {
pastTrips: pastTrips.length,
daysAway,
countriesVisited: countryCounts.size,
citiesVisited: cities.size,
topCountries
}
};
}

View File

@@ -600,14 +600,21 @@ function seedCities(db: Database): void {
const cities: { name: string; country: string; country_code: string; population: number }[] =
JSON.parse(data);
let id = 1;
db.run('BEGIN');
for (const city of cities) {
db.run(
'INSERT OR IGNORE INTO cities (id, name, country, country_code, population) VALUES (?, ?, ?, ?, ?)',
[id++, city.name, city.country, city.country_code, city.population]
);
}
db.run('COMMIT');
console.log(`[db] Seeded ${cities.length} cities`);
} catch (e) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
console.error('[db] Failed to seed cities:', e);
}
}
@@ -622,6 +629,7 @@ function seedAirports(db: Database): void {
const lines = data.split('\n').filter((line) => line.trim());
let imported = 0;
let skipped = 0;
db.run('BEGIN');
for (const line of lines) {
// Skip comments
@@ -676,9 +684,15 @@ function seedAirports(db: Database): void {
skipped++;
}
}
db.run('COMMIT');
console.log(`[db] Seeded ${imported} airports from airports.dat (${skipped} skipped)`);
return;
} catch (_fileError) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
// File doesn't exist, fall back to minimal seed only if table is empty
const airportsRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airports');
if ((airportsRow?.count ?? 0) === 0) {
@@ -782,6 +796,7 @@ function seedAirports(db: Database): void {
];
let id = 1;
db.run('BEGIN');
for (const airport of majorAirports) {
db.run(
`INSERT OR IGNORE INTO airports (id, iata_code, icao_code, name, city, country, country_code, latitude, longitude, timezone)
@@ -800,8 +815,14 @@ function seedAirports(db: Database): void {
]
);
}
db.run('COMMIT');
console.log(`[db] Seeded ${majorAirports.length} airports`);
} catch (e) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
console.error('[db] Failed to seed airports:', e);
}
}
@@ -816,6 +837,7 @@ function seedAirlines(db: Database): void {
const lines = data.split('\n').filter((line) => line.trim());
let imported = 0;
let skipped = 0;
db.run('BEGIN');
for (const line of lines) {
// Skip comments
@@ -857,9 +879,15 @@ function seedAirlines(db: Database): void {
skipped++;
}
}
db.run('COMMIT');
console.log(`[db] Seeded ${imported} airlines from airlines.dat (${skipped} skipped)`);
return;
} catch (_fileError) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
// File doesn't exist, fall back to minimal seed only if table is empty
const airlinesRow = db.get<{ count: number }>('SELECT COUNT(*) as count FROM airlines');
if ((airlinesRow?.count ?? 0) === 0) {
@@ -921,6 +949,7 @@ function seedAirlines(db: Database): void {
];
let id = 1;
db.run('BEGIN');
for (const airline of majorAirlines) {
db.run(
`INSERT OR IGNORE INTO airlines (id, iata_code, icao_code, name, country, country_code)
@@ -928,8 +957,14 @@ function seedAirlines(db: Database): void {
[id++, airline.iata, airline.icao, airline.name, airline.country, airline.country_code]
);
}
db.run('COMMIT');
console.log(`[db] Seeded ${majorAirlines.length} airlines`);
} catch (e) {
try {
db.run('ROLLBACK');
} catch {
/* ignore rollback failure */
}
console.error('[db] Failed to seed airlines:', e);
}
}

295
src/lib/timeline.test.ts Normal file
View File

@@ -0,0 +1,295 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { buildTripTimeline } from './timeline.js';
import type { Plan } from './server/plans.js';
import type { PackageTour, TourDay } from './server/package-tours.js';
let planCounter = 0;
function makePlan(overrides: Partial<Plan>): Plan {
planCounter += 1;
return {
id: overrides.id ?? `plan-${planCounter}`,
trip_id: 'trip-1',
user_id: 'user-1',
type: overrides.type ?? 'destination',
status: overrides.status ?? 'idea',
parent_id: overrides.parent_id ?? null,
title: overrides.title ?? 'Plan',
notes: overrides.notes ?? null,
city_id: overrides.city_id ?? null,
city_name: overrides.city_name ?? null,
country: overrides.country ?? null,
country_code: overrides.country_code ?? null,
start_date: overrides.start_date ?? null,
end_date: overrides.end_date ?? null,
position: overrides.position ?? 0,
created_at: overrides.created_at ?? '2026-01-01T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-01-01T00:00:00Z'
};
}
function makeTour(overrides: Partial<PackageTour>): PackageTour {
return {
id: overrides.id ?? 'tour-1',
plan_id: overrides.plan_id ?? 'tour-plan-1',
operator_name: overrides.operator_name ?? 'Adventure Co',
tour_name: overrides.tour_name ?? 'Rainforest',
confirmation_number: overrides.confirmation_number ?? null,
start_date: overrides.start_date ?? null,
start_time: overrides.start_time ?? null,
start_timezone: overrides.start_timezone ?? null,
end_date: overrides.end_date ?? null,
end_time: overrides.end_time ?? null,
end_timezone: overrides.end_timezone ?? null,
price: overrides.price ?? null,
currency: overrides.currency ?? 'USD',
created_at: overrides.created_at ?? '2026-01-01T00:00:00Z',
updated_at: overrides.updated_at ?? '2026-01-01T00:00:00Z'
};
}
function makeTourDay(overrides: Partial<TourDay>): TourDay {
return {
plan_id: overrides.plan_id ?? 'day-plan-1',
day_number: overrides.day_number ?? 1,
title: overrides.title ?? null,
notes: overrides.notes ?? null,
childPlans: overrides.childPlans ?? []
};
}
describe('buildTripTimeline', () => {
beforeEach(() => {
planCounter = 0;
});
it('orders scheduled plans by date and time', () => {
const early = makePlan({ type: 'activity', start_date: '2026-05-01' });
const later = makePlan({ type: 'activity', start_date: '2026-05-01' });
const nextDay = makePlan({ type: 'destination', start_date: '2026-05-02' });
const timeline = buildTripTimeline({
plans: [nextDay, later, early],
flightBookings: [],
privateVehicles: [],
otherTransports: [],
lodgings: [],
activities: [
{
plan_id: early.id,
id: 'exp-1',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: early.start_date,
start_time: '09:00',
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
},
{
plan_id: later.id,
id: 'exp-2',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: later.start_date,
start_time: '10:30',
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
],
restaurants: [],
packingLists: [],
todos: [],
packageTours: []
});
expect(timeline.scheduled).toHaveLength(2);
expect(timeline.scheduled[0].date).toBe('2026-05-01');
const ids = timeline.scheduled[0].entries
.filter((entry) => entry.kind === 'plan')
.map((entry) => (entry.kind === 'plan' ? entry.plan.id : ''));
expect(ids).toEqual([early.id, later.id]);
expect(timeline.scheduled[1].date).toBe('2026-05-02');
});
it('maps tour day items to tour start date offsets', () => {
const tourPlan = makePlan({ id: 'tour-plan-1', type: 'tour' });
const day1 = makeTourDay({ plan_id: 'day-1', day_number: 1, title: 'Arrival' });
const day2 = makeTourDay({ plan_id: 'day-2', day_number: 2, title: 'Hike' });
const activityPlan = makePlan({ id: 'activity-1', type: 'activity', parent_id: day2.plan_id });
const tour = makeTour({ plan_id: tourPlan.id, start_date: '2027-01-01' });
const timeline = buildTripTimeline({
plans: [tourPlan, activityPlan],
flightBookings: [],
privateVehicles: [],
otherTransports: [],
lodgings: [],
activities: [
{
plan_id: activityPlan.id,
id: 'exp-3',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: null,
start_time: null,
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
],
restaurants: [],
packingLists: [],
todos: [],
packageTours: [
{ ...tour, days: [day1, day2], ungroupedChildPlans: [], highlight_color: '#FFAA00' }
]
});
const day2Group = timeline.scheduled.find((group) => group.date === '2027-01-02');
expect(day2Group).toBeTruthy();
const dayEntries = day2Group?.entries.filter((entry) => entry.kind === 'day') ?? [];
expect(dayEntries).toHaveLength(1);
const planEntries = day2Group?.entries.filter((entry) => entry.kind === 'plan') ?? [];
expect(planEntries).toHaveLength(1);
expect(planEntries[0].kind === 'plan' ? planEntries[0].tourDay?.dayNumber : null).toBe(2);
});
it('puts tour days without a start date into unscheduled', () => {
const tourPlan = makePlan({ id: 'tour-plan-2', type: 'tour' });
const day1 = makeTourDay({ plan_id: 'day-3', day_number: 1, title: 'Intro' });
const activityPlan = makePlan({ id: 'activity-2', type: 'activity', parent_id: day1.plan_id });
const tour = makeTour({ id: 'tour-2', plan_id: tourPlan.id, start_date: null });
const timeline = buildTripTimeline({
plans: [tourPlan, activityPlan],
flightBookings: [],
privateVehicles: [],
otherTransports: [],
lodgings: [],
activities: [
{
plan_id: activityPlan.id,
id: 'exp-4',
booking_id: null,
total_cost: null,
description: null,
website: null,
address: null,
contact_number: null,
start_date: null,
start_time: null,
start_timezone: null,
end_date: null,
end_time: null,
end_timezone: null,
created_at: '2026-01-01',
updated_at: '2026-01-01'
}
],
restaurants: [],
packingLists: [],
todos: [],
packageTours: [{ ...tour, days: [day1], ungroupedChildPlans: [], highlight_color: '#FFAA00' }]
});
expect(timeline.scheduled).toHaveLength(0);
expect(timeline.unscheduled.some((entry) => entry.kind === 'day')).toBe(true);
expect(
timeline.unscheduled.some(
(entry) => entry.kind === 'plan' && entry.plan.id === activityPlan.id
)
).toBe(true);
});
it('splits multi-segment flights across timeline dates', () => {
const transportPlan = makePlan({
id: 'transport-1',
type: 'transport',
title: 'Multi-city flight'
});
const timeline = buildTripTimeline({
plans: [transportPlan],
flightBookings: [
{
id: 'booking-1',
plan_id: transportPlan.id,
confirmation_number: null,
price: null,
currency: 'USD',
created_at: '2026-01-01',
updated_at: '2026-01-01',
segments: [
{
id: 'seg-1',
booking_id: 'booking-1',
position: 0,
departure_date: '2026-07-10',
airline_id: null,
airline_iata: 'UA',
airline_name: 'United',
flight_number: '100',
created_at: '2026-01-01',
updated_at: '2026-01-01',
route: null
},
{
id: 'seg-2',
booking_id: 'booking-1',
position: 1,
departure_date: '2026-07-12',
airline_id: null,
airline_iata: 'UA',
airline_name: 'United',
flight_number: '200',
created_at: '2026-01-01',
updated_at: '2026-01-01',
route: null
}
]
}
],
privateVehicles: [],
otherTransports: [],
lodgings: [],
activities: [],
restaurants: [],
packingLists: [],
todos: [],
packageTours: []
});
expect(timeline.scheduled).toHaveLength(2);
expect(timeline.scheduled[0].date).toBe('2026-07-10');
expect(timeline.scheduled[1].date).toBe('2026-07-12');
const firstEntry = timeline.scheduled[0].entries[0];
const secondEntry = timeline.scheduled[1].entries[0];
expect(firstEntry.kind === 'plan' ? firstEntry.flightSegmentIndex : null).toBe(0);
expect(secondEntry.kind === 'plan' ? secondEntry.flightSegmentIndex : null).toBe(1);
expect(firstEntry.id).not.toBe(secondEntry.id);
});
});

321
src/lib/timeline.ts Normal file
View File

@@ -0,0 +1,321 @@
import type { Plan } from '$lib/server/plans.js';
import type { FlightBooking } from '$lib/server/flights.js';
import type { PrivateVehicleTransport } from '$lib/server/private-vehicles.js';
import type { OtherTransport } from '$lib/server/other-transports.js';
import type { Lodging } from '$lib/server/lodgings.js';
import type { ExperiencePlan } from '$lib/server/experiences.js';
import type { ChecklistWithItems } from '$lib/server/checklists.js';
import type { PackageTour, TourDay, ChildPlanSummary } from '$lib/server/package-tours.js';
export interface TripTimelineInput {
plans: Plan[];
flightBookings: Array<
FlightBooking & {
segments: Array<{
departure_date: string;
route?: { departure_datetime?: string | null } | null;
}>;
}
>;
privateVehicles: PrivateVehicleTransport[];
otherTransports: OtherTransport[];
lodgings: Array<Lodging & { planStatus?: string }>;
activities: ExperiencePlan[];
restaurants: ExperiencePlan[];
packingLists: ChecklistWithItems[];
todos: ChecklistWithItems[];
packageTours: Array<
PackageTour & {
days: TourDay[];
ungroupedChildPlans: ChildPlanSummary[];
highlight_color: string | null;
}
>;
}
export interface TourContext {
planId: string;
tourId: string;
operatorName: string;
tourName: string | null;
startDate: string | null;
startTime: string | null;
highlightColor: string | null;
}
export interface TourDayContext {
planId: string;
dayNumber: number;
title: string | null;
}
export type TimelineEntry =
| {
kind: 'day';
id: string;
date: string | null;
tour: TourContext;
day: TourDayContext;
sortRank: number;
sortTime: string;
sequence: number;
}
| {
kind: 'plan';
id: string;
date: string | null;
time: string | null;
plan: Plan;
flightSegmentIndex: number | null;
tour: TourContext | null;
tourDay: TourDayContext | null;
sortRank: number;
sortTime: string;
sequence: number;
};
export interface TimelineGroup {
date: string;
entries: TimelineEntry[];
}
export interface TripTimeline {
scheduled: TimelineGroup[];
unscheduled: TimelineEntry[];
}
function addDaysToDate(date: string, offset: number): string {
const [year, month, day] = date.split('-').map(Number);
const ts = Date.UTC(year, month - 1, day + offset);
return new Date(ts).toISOString().slice(0, 10);
}
function normalizeTime(value: string | null | undefined): string | null {
if (!value) return null;
return value.slice(0, 5);
}
function splitDateTime(value: string | null | undefined): {
date: string | null;
time: string | null;
} {
if (!value) return { date: null, time: null };
const [date, time] = value.split('T');
return { date: date ?? null, time: normalizeTime(time) };
}
export function buildTripTimeline(input: TripTimelineInput): TripTimeline {
const flightByPlanId = new Map(input.flightBookings.map((booking) => [booking.plan_id, booking]));
const privateVehicleByPlanId = new Map(
input.privateVehicles.map((vehicle) => [vehicle.plan_id, vehicle])
);
const otherTransportByPlanId = new Map(
input.otherTransports.map((transport) => [transport.plan_id, transport])
);
const lodgingByPlanId = new Map(input.lodgings.map((lodging) => [lodging.plan_id, lodging]));
const experienceByPlanId = new Map(
[...input.activities, ...input.restaurants].map((experience) => [
experience.plan_id,
experience
])
);
const tourByPlanId = new Map(
input.packageTours.map((tour) => [
tour.plan_id,
{
planId: tour.plan_id,
tourId: tour.id,
operatorName: tour.operator_name,
tourName: tour.tour_name ?? null,
startDate: tour.start_date ?? null,
startTime: tour.start_time ?? null,
highlightColor: tour.highlight_color ?? null
} satisfies TourContext
])
);
const dayByPlanId = new Map<string, { day: TourDayContext; tour: TourContext }>();
for (const tour of input.packageTours) {
const tourContext = tourByPlanId.get(tour.plan_id);
if (!tourContext) continue;
for (const day of tour.days) {
dayByPlanId.set(day.plan_id, {
day: { planId: day.plan_id, dayNumber: day.day_number, title: day.title ?? null },
tour: tourContext
});
}
}
const entries: TimelineEntry[] = [];
let sequence = 0;
for (const plan of input.plans) {
if (plan.type === 'day') continue;
const dayContext = plan.parent_id ? dayByPlanId.get(plan.parent_id) : null;
const tourContext =
dayContext?.tour ??
(plan.parent_id ? (tourByPlanId.get(plan.parent_id) ?? null) : null) ??
tourByPlanId.get(plan.id) ??
null;
let date: string | null = null;
let time: string | null = null;
switch (plan.type) {
case 'destination':
date = plan.start_date ?? plan.end_date ?? null;
break;
case 'transport': {
const flight = flightByPlanId.get(plan.id);
if (flight?.segments?.length) {
for (let segmentIndex = 0; segmentIndex < flight.segments.length; segmentIndex += 1) {
const segment = flight.segments[segmentIndex];
const routeDateTime = splitDateTime(segment.route?.departure_datetime ?? null);
let segmentDate = routeDateTime.date ?? segment.departure_date ?? null;
const segmentTime = routeDateTime.time;
if (dayContext?.tour.startDate) {
segmentDate = addDaysToDate(dayContext.tour.startDate, dayContext.day.dayNumber - 1);
}
const sortTime = segmentTime ?? '24:00';
entries.push({
kind: 'plan',
id: `${plan.id}-segment-${segmentIndex}`,
date: segmentDate,
time: segmentTime,
plan,
flightSegmentIndex: segmentIndex,
tour: tourContext,
tourDay: dayContext?.day ?? null,
sortRank: 1,
sortTime,
sequence: sequence++
});
}
continue;
}
const privateVehicle = privateVehicleByPlanId.get(plan.id);
if (privateVehicle) {
date = privateVehicle.departure_date ?? privateVehicle.arrival_date ?? null;
time =
normalizeTime(privateVehicle.departure_time) ??
normalizeTime(privateVehicle.arrival_time);
break;
}
const otherTransport = otherTransportByPlanId.get(plan.id);
if (otherTransport) {
date = otherTransport.start_date ?? otherTransport.end_date ?? null;
time = normalizeTime(otherTransport.start_time) ?? normalizeTime(otherTransport.end_time);
}
break;
}
case 'lodging': {
const lodging = lodgingByPlanId.get(plan.id);
if (lodging) {
date = lodging.check_in_date ?? lodging.check_out_date ?? null;
time = normalizeTime(lodging.check_in_time) ?? normalizeTime(lodging.check_out_time);
}
break;
}
case 'activity':
case 'restaurant': {
const experience = experienceByPlanId.get(plan.id);
if (experience) {
date = experience.start_date ?? experience.end_date ?? null;
time = normalizeTime(experience.start_time) ?? normalizeTime(experience.end_time);
}
break;
}
case 'tour': {
if (tourContext) {
date = tourContext.startDate;
time = normalizeTime(tourContext.startTime);
}
break;
}
case 'packing':
case 'todo':
break;
default:
break;
}
if (dayContext?.tour.startDate) {
date = addDaysToDate(dayContext.tour.startDate, dayContext.day.dayNumber - 1);
}
const sortTime = time ?? '24:00';
entries.push({
kind: 'plan',
id: plan.id,
date,
time,
plan,
flightSegmentIndex: null,
tour: tourContext,
tourDay: dayContext?.day ?? null,
sortRank: 1,
sortTime,
sequence: sequence++
});
}
for (const tour of input.packageTours) {
const tourContext = tourByPlanId.get(tour.plan_id);
if (!tourContext) continue;
for (const day of tour.days) {
const dayContext: TourDayContext = {
planId: day.plan_id,
dayNumber: day.day_number,
title: day.title ?? null
};
const dayDate = tour.start_date ? addDaysToDate(tour.start_date, day.day_number - 1) : null;
entries.push({
kind: 'day',
id: `day-${day.plan_id}`,
date: dayDate,
tour: tourContext,
day: dayContext,
sortRank: 0,
sortTime: '00:00',
sequence: sequence++
});
}
}
const scheduledEntries = entries.filter((entry) => entry.date);
const unscheduledEntries = entries
.filter((entry) => !entry.date)
.sort((a, b) => {
if (a.sortRank !== b.sortRank) return a.sortRank - b.sortRank;
if (a.sortTime !== b.sortTime) return a.sortTime.localeCompare(b.sortTime);
return a.sequence - b.sequence;
});
scheduledEntries.sort((a, b) => {
if (a.date !== b.date) return (a.date ?? '').localeCompare(b.date ?? '');
if (a.sortRank !== b.sortRank) return a.sortRank - b.sortRank;
if (a.sortTime !== b.sortTime) return a.sortTime.localeCompare(b.sortTime);
return a.sequence - b.sequence;
});
const grouped = new Map<string, TimelineEntry[]>();
for (const entry of scheduledEntries) {
const date = entry.date as string;
const list = grouped.get(date);
if (list) list.push(entry);
else grouped.set(date, [entry]);
}
const scheduled: TimelineGroup[] = Array.from(grouped.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, groupEntries]) => ({
date,
entries: groupEntries
}));
return { scheduled, unscheduled: unscheduledEntries };
}

View File

@@ -0,0 +1,13 @@
import { error } from '@sveltejs/kit';
import { getDashboardData } from '$lib/server/dashboard.js';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async (event) => {
const session = await event.locals.auth();
const userId = session?.user?.id;
if (!userId) error(401, 'Not authenticated');
return {
dashboard: getDashboardData(userId)
};
};

View File

@@ -1,10 +1,424 @@
<script lang="ts">
import { base } from '$app/paths';
import type { DashboardData, DashboardMapStop } from '$lib/server/dashboard.js';
let { data } = $props();
const dashboard = $derived(data.dashboard) as DashboardData;
const mapLongitudeOffset = 18;
const mapLatitudeOffset = 4;
const markers = $derived(
dashboard.mapStops.map((stop: DashboardMapStop, index: number) => {
const x = ((stop.lon + 180 - mapLongitudeOffset) / 360) * 100;
const y = ((90 - (stop.lat + mapLatitudeOffset)) / 180) * 100;
return {
...stop,
id: `${stop.tripId}-${index}`,
x: Math.min(98, Math.max(2, x)),
y: Math.min(96, Math.max(4, y))
};
})
);
function parseDate(value: string | null): Date | null {
if (!value) return null;
const parsed = new Date(`${value}T00:00:00`);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function formatDate(value: string | null): string {
if (!value) return 'TBD';
return new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
});
}
function formatRange(start: string | null, end: string | null): string {
if (!start && !end) return 'Dates not set yet';
return `${formatDate(start)}${formatDate(end)}`;
}
function dayDiffInclusive(start: string | null, end: string | null): number | null {
const startDate = parseDate(start);
const endDate = parseDate(end);
if (!startDate || !endDate) return null;
const diffMs = endDate.getTime() - startDate.getTime();
if (Number.isNaN(diffMs)) return null;
return Math.max(0, Math.round(diffMs / 86400000) + 1);
}
function progressPercent(start: string | null, end: string | null): number | null {
const startDate = parseDate(start);
const endDate = parseDate(end);
if (!startDate || !endDate) return null;
const now = new Date();
const totalMs = endDate.getTime() - startDate.getTime();
if (totalMs <= 0) return null;
const elapsedMs = now.getTime() - startDate.getTime();
return Math.min(100, Math.max(0, Math.round((elapsedMs / totalMs) * 100)));
}
function daysUntil(value: string | null): number | null {
const target = parseDate(value);
if (!target) return null;
const now = new Date();
const diffMs = target.getTime() - now.getTime();
return Math.ceil(diffMs / 86400000);
}
function currentTripDay(start: string | null, end: string | null): { current: number; total: number } | null {
const startDate = parseDate(start);
const endDate = parseDate(end);
const total = dayDiffInclusive(start, end);
if (!startDate || !endDate || !total) return null;
const now = new Date();
const elapsed = Math.floor((now.getTime() - startDate.getTime()) / 86400000) + 1;
return { current: Math.min(total, Math.max(1, elapsed)), total };
}
</script>
<svelte:head>
<title>Dashboard — Trips</title>
</svelte:head>
<h1 class="mb-2 text-2xl font-bold text-gray-900">Dashboard</h1>
<p class="text-gray-600">Welcome, {data.session.user?.name ?? data.session.user?.email}.</p>
<div class="dashboard-shell space-y-8">
<header class="rounded-3xl bg-white p-6 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Overview</p>
<h1 class="display mt-2 text-3xl text-slate-900 sm:text-4xl">Dashboard</h1>
<p class="mt-2 text-sm text-slate-600">
Welcome back, {data.session.user?.name ?? data.session.user?.email}. Heres a quick pulse on your
travel story.
</p>
</header>
<section class="grid gap-6 lg:grid-cols-[1.6fr,1fr]">
<div
class="relative overflow-hidden rounded-3xl border border-slate-200 bg-slate-950/90 p-6 text-white"
data-testid="trip-map"
>
<div class="absolute inset-0 map-grid opacity-15"></div>
<div class="absolute -left-20 top-10 h-48 w-48 rounded-full bg-emerald-400/15 blur-3xl"></div>
<div class="absolute right-0 bottom-0 h-56 w-56 rounded-full bg-sky-400/15 blur-3xl"></div>
<img
class="map-base absolute inset-0 h-full w-full object-cover"
src="{base}/world-map.svg"
alt=""
aria-hidden="true"
/>
<div class="relative">
<div class="flex items-start justify-between gap-4">
<div>
<p class="text-xs uppercase tracking-[0.2em] text-emerald-200">World map</p>
<h2 class="display mt-2 text-xl text-white">Past &amp; future routes</h2>
<p class="mt-2 text-sm text-slate-200/80">
Pins highlight places from your journey log and whats still ahead.
</p>
</div>
<div class="flex flex-col gap-2 text-xs text-slate-200">
<div class="flex items-center gap-2">
<span class="legend-dot legend-past"></span>
Past trips
</div>
<div class="flex items-center gap-2">
<span class="legend-dot legend-current"></span>
In progress
</div>
<div class="flex items-center gap-2">
<span class="legend-dot legend-future"></span>
Upcoming
</div>
</div>
</div>
<div class="relative mt-6 h-64">
{#if markers.length === 0}
<div class="flex h-full items-center justify-center rounded-2xl border border-dashed border-white/30 text-sm text-slate-200">
Add destinations to your trips to light up the map.
</div>
{:else}
{#each markers as marker}
<span
class="map-marker map-marker--{marker.status}"
data-testid="map-stop"
data-status={marker.status}
style={`left:${marker.x}%; top:${marker.y}%;`}
title={`${marker.tripName} · ${marker.label}`}
></span>
{/each}
{/if}
</div>
</div>
</div>
<div class="grid gap-4">
<div class="rounded-3xl border border-slate-200 bg-white p-5 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Travel diary</p>
<h2 class="display mt-2 text-xl text-slate-900">Where youve been</h2>
<div class="mt-4 grid grid-cols-2 gap-4">
<div class="stat-card">
<span class="stat-label">Past trips</span>
<span class="stat-value" data-testid="stat-past-trips">{dashboard.stats.pastTrips}</span>
</div>
<div class="stat-card">
<span class="stat-label">Days away</span>
<span class="stat-value" data-testid="stat-days-away">{dashboard.stats.daysAway}</span>
</div>
<div class="stat-card">
<span class="stat-label">Countries visited</span>
<span class="stat-value" data-testid="stat-countries">{dashboard.stats.countriesVisited}</span>
</div>
<div class="stat-card">
<span class="stat-label">Cities explored</span>
<span class="stat-value" data-testid="stat-cities">{dashboard.stats.citiesVisited}</span>
</div>
</div>
<div class="mt-5">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Top destinations</p>
{#if dashboard.stats.topCountries.length === 0}
<p class="mt-2 text-sm text-slate-500">No past destinations yet.</p>
{:else}
<div class="mt-3 space-y-2">
{#each dashboard.stats.topCountries as country}
<div class="flex items-center justify-between text-sm text-slate-700">
<span>{country.country}</span>
<span class="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-600">
{country.count} stop{country.count === 1 ? '' : 's'}
</span>
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-slate-900 via-slate-900 to-emerald-900 p-5 text-white shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-emerald-200">Next up</p>
<h2 class="display mt-2 text-xl">Trips in progress</h2>
{#if dashboard.inProgressTrips.length === 0}
<p class="mt-3 text-sm text-emerald-100/80">No trips are underway right now.</p>
{:else}
<div class="mt-4 space-y-3">
{#each dashboard.inProgressTrips as trip}
<a
href="{base}/trips/{trip.id}"
class="block rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white">{trip.name}</h3>
<span class="badge badge-current">In progress</span>
</div>
<p class="mt-1 text-xs text-emerald-100/80">
{formatRange(trip.start_date, trip.end_date)}
</p>
{#if progressPercent(trip.start_date, trip.end_date) !== null}
<div class="mt-3 h-1.5 w-full rounded-full bg-white/10">
<div
class="h-1.5 rounded-full bg-emerald-300"
style={`width:${progressPercent(trip.start_date, trip.end_date)}%`}
></div>
</div>
{/if}
<p class="mt-2 text-xs text-emerald-100/70">
{#if currentTripDay(trip.start_date, trip.end_date)}
Day {currentTripDay(trip.start_date, trip.end_date)?.current} of {currentTripDay(
trip.start_date,
trip.end_date
)?.total}
·
{/if}
{trip.planCount} plans logged
</p>
</a>
{/each}
</div>
{/if}
</div>
</div>
</section>
<section class="grid gap-6 lg:grid-cols-2">
<div class="rounded-3xl border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex items-center justify-between">
<div>
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">On the horizon</p>
<h2 class="display mt-2 text-xl text-slate-900">Trips being planned</h2>
</div>
<a href="{base}/trips/upcoming" class="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 hover:text-slate-800">
View all
</a>
</div>
{#if dashboard.plannedTrips.length === 0}
<p class="mt-4 text-sm text-slate-500">No future trips yet. Start planning the next adventure.</p>
{:else}
<div class="mt-4 space-y-3">
{#each dashboard.plannedTrips as trip}
<a
href="{base}/trips/{trip.id}"
class="block rounded-2xl border border-slate-200 bg-slate-50 p-4 transition hover:bg-white"
>
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-slate-900">{trip.name}</h3>
<span class="badge badge-future">Planning</span>
</div>
<p class="mt-1 text-xs text-slate-500">{formatRange(trip.start_date, trip.end_date)}</p>
<p class="mt-2 text-xs text-slate-600">
{#if daysUntil(trip.start_date) !== null}
Starts in {daysUntil(trip.start_date)} day{daysUntil(trip.start_date) === 1 ? '' : 's'}
{:else}
Dates to be confirmed
{/if}
· {trip.planCount} plans sketched
</p>
</a>
{/each}
</div>
{/if}
</div>
<div class="rounded-3xl border border-slate-200 bg-gradient-to-br from-white via-white to-sky-50 p-6 shadow-sm">
<p class="text-xs uppercase tracking-[0.2em] text-slate-500">Quick actions</p>
<h2 class="display mt-2 text-xl text-slate-900">Build your next story</h2>
<p class="mt-2 text-sm text-slate-600">
Add a destination, log transportation, or save an idea to keep your timeline evolving.
</p>
<div class="mt-4 flex flex-wrap gap-3">
<a
href="{base}/trips/new"
class="rounded-full bg-slate-900 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-white transition hover:bg-slate-800"
>
Plan new trip
</a>
<a
href="{base}/trips/upcoming"
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 transition hover:border-slate-400 hover:text-slate-900"
>
Browse upcoming
</a>
<a
href="{base}/trips/past"
class="rounded-full border border-slate-300 px-4 py-2 text-xs font-semibold uppercase tracking-[0.2em] text-slate-600 transition hover:border-slate-400 hover:text-slate-900"
>
Revisit past trips
</a>
</div>
</div>
</section>
</div>
<style>
.map-grid {
background-image: linear-gradient(90deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px),
linear-gradient(0deg, rgba(255, 255, 255, 0.08) 1px, transparent 1px);
background-size: 48px 48px;
}
.map-base {
opacity: 0.45;
filter: invert(1) grayscale(1);
}
.map-marker {
position: absolute;
width: 0.8rem;
height: 0.8rem;
border-radius: 999px;
transform: translate(-50%, -50%);
box-shadow: 0 0 0 4px rgba(255, 255, 255, 0.15), 0 8px 20px rgba(15, 23, 42, 0.35);
}
.map-marker--past {
background: #e2e8f0;
border: 2px solid #94a3b8;
}
.map-marker--current {
background: #34d399;
border: 2px solid #0f766e;
animation: pulse 2.4s ease-in-out infinite;
}
.map-marker--future {
background: #fbbf24;
border: 2px solid #b45309;
}
.legend-dot {
height: 0.6rem;
width: 0.6rem;
border-radius: 999px;
border: 2px solid transparent;
display: inline-block;
}
.legend-past {
background: #e2e8f0;
border-color: #94a3b8;
}
.legend-current {
background: #34d399;
border-color: #0f766e;
}
.legend-future {
background: #fbbf24;
border-color: #b45309;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0.35rem;
border-radius: 1rem;
background: #f8fafc;
padding: 0.9rem;
}
.stat-label {
font-size: 0.7rem;
letter-spacing: 0.15em;
text-transform: uppercase;
color: #64748b;
}
.stat-value {
font-size: 1.6rem;
font-weight: 600;
color: #0f172a;
}
.badge {
border-radius: 999px;
padding: 0.25rem 0.6rem;
font-size: 0.65rem;
text-transform: uppercase;
letter-spacing: 0.15em;
}
.badge-current {
background: rgba(52, 211, 153, 0.2);
color: #a7f3d0;
border: 1px solid rgba(52, 211, 153, 0.4);
}
.badge-future {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
@keyframes pulse {
0%,
100% {
transform: translate(-50%, -50%) scale(1);
box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.3), 0 8px 20px rgba(15, 23, 42, 0.35);
}
50% {
transform: translate(-50%, -50%) scale(1.12);
box-shadow: 0 0 0 8px rgba(52, 211, 153, 0.18), 0 8px 20px rgba(15, 23, 42, 0.35);
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,18 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
<title>LATAM Airlines Chile</title>
<desc>LATAM Airlines Chile logo</desc>
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
<rdf:RDF>
<Airline:name>LATAM Airlines Chile</Airline:name>
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
<Airline:iataCode>LA</Airline:iataCode>
<Airline:url>https://airlinelogos.aero/LA</Airline:url>
</rdf:RDF>
</metadata>
<path d="M129.669 47.129v13.726h2.162V47.129h-2.162zm37.725 0v13.726h11.085v-2.002h-8.924v-4.294h7.771v-1.857h-7.771v-3.715h8.632V47.13h-10.793zm-91.428 0v13.726h2.161V47.129h-2.161zm35.696 13.739h10.078v-1.857h-7.916V47.142h-2.162v13.726zm-16.548-6.007c-1.578 0-3.169-.435-4.177-1.001v-5.15h5.622c2.732 0 3.739 1.436 3.739 2.714 0 1.857-1.445 3.438-5.184 3.438zm-33.402.566h-6.338l3.169-6.442 3.169 6.442zm82.782 5.44V49.566l9.507 10.723c.292.435.716.566 1.3.566h1.299V47.129h-2.015v10.868l-9.362-10.301c-.292-.435-.57-.567-1.299-.567h-1.446v13.726h2.016v.013zm-53.557 0v-5.006c1.008.566 2.453.856 4.031.856.862 0 1.445 0 2.161-.145l2.878 4.294h2.307l-3.169-4.861c2.015-.856 3.315-2.569 3.315-4.571 0-2.569-2.162-4.439-6.047-4.439h-7.638v13.871h2.161zm-26.639 0h2.161l-6.484-13.16c-.292-.435-.57-.566-1.008-.566h-1.578l-6.763 13.726h2.148l1.724-3.57h8.062l1.737 3.57zm130.015-14.029c-3.74 0-6.193 1.581-6.193 4.005 0 2.292 1.87 3.148 4.893 3.715l1.445.29c2.162.435 3.315 1.146 3.315 2.292s-1.153 2.002-4.031 2.002c-2.585 0-4.614-.856-5.184-1.291l-.57 2.002c.57.29 2.585 1.146 5.609 1.146 4.031 0 6.338-1.568 6.338-4.005 0-2.292-2.016-3.438-5.185-4.004l-1.445-.29c-2.307-.435-3.023-.856-3.023-2.002s1.299-2.002 3.885-2.002c2.161 0 3.739.435 4.468.711l.438-1.712c-.875-.277-2.453-.856-4.76-.856zm-80.158-14.7h6.073V15.33l10.608-.698v-4.334h-27.289v4.334l10.608.698v16.808zm-43.837 0l2.493-5.032H58.079V10.298h-6.312v21.84h18.551zm23.47-8.444h-8.181l4.137-8.747 4.044 8.747zm54.909 0h-8.194l4.137-8.747 4.057 8.747zm27.753 3.109c.239.777 1.167 1.317 2.188 1.317h6.312l4.681-12.857 4.442 16.888H200l-4.906-19.364c-.464-1.936-1.565-2.476-3.355-2.476h-6.166l-4.442 12.778-3.514-10.854c-.464-1.383-1.326-1.936-2.731-1.936h-6.935l-5.689 21.84h6.007l4.363-16.887 3.818 11.553zm-23.629 5.335h6.63l-10.449-20.457c-.543-1.001-1.087-1.396-2.334-1.396H140.9l-11.005 21.854h6.709l1.949-4.031h12.319l1.949 4.031zm-54.976 0h6.71L94.106 11.694c-.544-1.001-1.087-1.396-2.347-1.396h-5.768l-10.992 21.84h6.696l1.949-4.031h12.319l1.883 4.031zM8.964 54.322l5.384-1.95c1.127-.408 1.127.487 1.127 1.831v2.463c0 1.989-.676 2.226-1.565 2.556L8.977 61v-6.679h-.013zm0-10.907l13.684-4.953c1.127-.408 1.127.487 1.127 1.831v2.45c0 2.002-.676 2.239-1.565 2.569L8.977 50.093v-6.679h-.013zM7.85 27.857L.902 25.341C0 25.025 0 23.813 0 23.366c0 0 0 1.462 1.339.975l34.078-12.316c2.692-.975 2.692-1.726 1.565-2.134 1.127.408 1.127.408 1.127 3.675 0 3.122 0 4.163-1.339 4.65l-26.679 9.642c-1.127.408-1.127.408-2.241 0zm29.146-7.06c1.127.408 1.127 1.159-1.565 2.134L8.964 32.494v6.679L36.77 29.122c1.339-.487 1.339-1.528 1.339-4.65 0-3.267 0-3.267-1.114-3.675z" fill="#1b0088"/>
<g fill="#ed1650">
<path d="M2.917 15.159l11.908 4.308 9.242-3.346L1.79 8.072C.225 7.505 0 7.426 0 8.546v2.898c0 2.661 2.016 3.399 2.917 3.715zm34.079 5.638l-3.7-1.33-9.242 3.346 5.848 2.121 5.516-1.989c2.692-.988 2.692-1.739 1.578-2.147z"/>
<path d="M36.996 9.89L10.767.405C9.189-.161 8.964-.24 8.964.866v2.898c0 2.674 2.016 3.399 2.917 3.728l18.034 6.521 5.516-1.989c2.678-.975 2.678-1.726 1.565-2.134zM1.339 24.34l7.624-2.753-7.174-2.595C.225 18.426 0 18.334 0 19.453v3.912s0 1.462 1.339.975z"/>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,18 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
<title>LATAM Airlines Paraguay</title>
<desc>LATAM Airlines Paraguay logo</desc>
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
<rdf:RDF>
<Airline:name>LATAM Airlines Paraguay</Airline:name>
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
<Airline:iataCode>PZ</Airline:iataCode>
<Airline:url>https://airlinelogos.aero/PZ</Airline:url>
</rdf:RDF>
</metadata>
<path d="M129.669 47.129v13.726h2.162V47.129h-2.162zm37.725 0v13.726h11.085v-2.002h-8.924v-4.294h7.771v-1.857h-7.771v-3.715h8.632V47.13h-10.793zm-91.428 0v13.726h2.161V47.129h-2.161zm35.696 13.739h10.078v-1.857h-7.916V47.142h-2.162v13.726zm-16.548-6.007c-1.578 0-3.169-.435-4.177-1.001v-5.15h5.622c2.732 0 3.739 1.436 3.739 2.714 0 1.857-1.445 3.438-5.184 3.438zm-33.402.566h-6.338l3.169-6.442 3.169 6.442zm82.782 5.44V49.566l9.507 10.723c.292.435.716.566 1.3.566h1.299V47.129h-2.015v10.868l-9.362-10.301c-.292-.435-.57-.567-1.299-.567h-1.446v13.726h2.016v.013zm-53.557 0v-5.006c1.008.566 2.453.856 4.031.856.862 0 1.445 0 2.161-.145l2.878 4.294h2.307l-3.169-4.861c2.015-.856 3.315-2.569 3.315-4.571 0-2.569-2.162-4.439-6.047-4.439h-7.638v13.871h2.161zm-26.639 0h2.161l-6.484-13.16c-.292-.435-.57-.566-1.008-.566h-1.578l-6.763 13.726h2.148l1.724-3.57h8.062l1.737 3.57zm130.015-14.029c-3.74 0-6.193 1.581-6.193 4.005 0 2.292 1.87 3.148 4.893 3.715l1.445.29c2.162.435 3.315 1.146 3.315 2.292s-1.153 2.002-4.031 2.002c-2.585 0-4.614-.856-5.184-1.291l-.57 2.002c.57.29 2.585 1.146 5.609 1.146 4.031 0 6.338-1.568 6.338-4.005 0-2.292-2.016-3.438-5.185-4.004l-1.445-.29c-2.307-.435-3.023-.856-3.023-2.002s1.299-2.002 3.885-2.002c2.161 0 3.739.435 4.468.711l.438-1.712c-.875-.277-2.453-.856-4.76-.856zm-80.158-14.7h6.073V15.33l10.608-.698v-4.334h-27.289v4.334l10.608.698v16.808zm-43.837 0l2.493-5.032H58.079V10.298h-6.312v21.84h18.551zm23.47-8.444h-8.181l4.137-8.747 4.044 8.747zm54.909 0h-8.194l4.137-8.747 4.057 8.747zm27.753 3.109c.239.777 1.167 1.317 2.188 1.317h6.312l4.681-12.857 4.442 16.888H200l-4.906-19.364c-.464-1.936-1.565-2.476-3.355-2.476h-6.166l-4.442 12.778-3.514-10.854c-.464-1.383-1.326-1.936-2.731-1.936h-6.935l-5.689 21.84h6.007l4.363-16.887 3.818 11.553zm-23.629 5.335h6.63l-10.449-20.457c-.543-1.001-1.087-1.396-2.334-1.396H140.9l-11.005 21.854h6.709l1.949-4.031h12.319l1.949 4.031zm-54.976 0h6.71L94.106 11.694c-.544-1.001-1.087-1.396-2.347-1.396h-5.768l-10.992 21.84h6.696l1.949-4.031h12.319l1.883 4.031zM8.964 54.322l5.384-1.95c1.127-.408 1.127.487 1.127 1.831v2.463c0 1.989-.676 2.226-1.565 2.556L8.977 61v-6.679h-.013zm0-10.907l13.684-4.953c1.127-.408 1.127.487 1.127 1.831v2.45c0 2.002-.676 2.239-1.565 2.569L8.977 50.093v-6.679h-.013zM7.85 27.857L.902 25.341C0 25.025 0 23.813 0 23.366c0 0 0 1.462 1.339.975l34.078-12.316c2.692-.975 2.692-1.726 1.565-2.134 1.127.408 1.127.408 1.127 3.675 0 3.122 0 4.163-1.339 4.65l-26.679 9.642c-1.127.408-1.127.408-2.241 0zm29.146-7.06c1.127.408 1.127 1.159-1.565 2.134L8.964 32.494v6.679L36.77 29.122c1.339-.487 1.339-1.528 1.339-4.65 0-3.267 0-3.267-1.114-3.675z" fill="#1b0088"/>
<g fill="#ed1650">
<path d="M2.917 15.159l11.908 4.308 9.242-3.346L1.79 8.072C.225 7.505 0 7.426 0 8.546v2.898c0 2.661 2.016 3.399 2.917 3.715zm34.079 5.638l-3.7-1.33-9.242 3.346 5.848 2.121 5.516-1.989c2.692-.988 2.692-1.739 1.578-2.147z"/>
<path d="M36.996 9.89L10.767.405C9.189-.161 8.964-.24 8.964.866v2.898c0 2.674 2.016 3.399 2.917 3.728l18.034 6.521 5.516-1.989c2.678-.975 2.678-1.726 1.565-2.134zM1.339 24.34l7.624-2.753-7.174-2.595C.225 18.426 0 18.334 0 19.453v3.912s0 1.462 1.339.975z"/>
</g>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,18 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 61">
<title>LATAM Airlines Ecuador</title>
<desc>LATAM Airlines Ecuador logo</desc>
<metadata xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:Airline="http://schema.org/Airline">
<rdf:RDF>
<Airline:name>LATAM Airlines Ecuador</Airline:name>
<Airline:alternateName>LATAM Airlines</Airline:alternateName>
<Airline:iataCode>XL</Airline:iataCode>
<Airline:url>https://airlinelogos.aero/XL</Airline:url>
</rdf:RDF>
</metadata>
<path d="M129.669 47.129v13.726h2.162V47.129h-2.162zm37.725 0v13.726h11.085v-2.002h-8.924v-4.294h7.771v-1.857h-7.771v-3.715h8.632V47.13h-10.793zm-91.428 0v13.726h2.161V47.129h-2.161zm35.696 13.739h10.078v-1.857h-7.916V47.142h-2.162v13.726zm-16.548-6.007c-1.578 0-3.169-.435-4.177-1.001v-5.15h5.622c2.732 0 3.739 1.436 3.739 2.714 0 1.857-1.445 3.438-5.184 3.438zm-33.402.566h-6.338l3.169-6.442 3.169 6.442zm82.782 5.44V49.566l9.507 10.723c.292.435.716.566 1.3.566h1.299V47.129h-2.015v10.868l-9.362-10.301c-.292-.435-.57-.567-1.299-.567h-1.446v13.726h2.016v.013zm-53.557 0v-5.006c1.008.566 2.453.856 4.031.856.862 0 1.445 0 2.161-.145l2.878 4.294h2.307l-3.169-4.861c2.015-.856 3.315-2.569 3.315-4.571 0-2.569-2.162-4.439-6.047-4.439h-7.638v13.871h2.161zm-26.639 0h2.161l-6.484-13.16c-.292-.435-.57-.566-1.008-.566h-1.578l-6.763 13.726h2.148l1.724-3.57h8.062l1.737 3.57zm130.015-14.029c-3.74 0-6.193 1.581-6.193 4.005 0 2.292 1.87 3.148 4.893 3.715l1.445.29c2.162.435 3.315 1.146 3.315 2.292s-1.153 2.002-4.031 2.002c-2.585 0-4.614-.856-5.184-1.291l-.57 2.002c.57.29 2.585 1.146 5.609 1.146 4.031 0 6.338-1.568 6.338-4.005 0-2.292-2.016-3.438-5.185-4.004l-1.445-.29c-2.307-.435-3.023-.856-3.023-2.002s1.299-2.002 3.885-2.002c2.161 0 3.739.435 4.468.711l.438-1.712c-.875-.277-2.453-.856-4.76-.856zm-80.158-14.7h6.073V15.33l10.608-.698v-4.334h-27.289v4.334l10.608.698v16.808zm-43.837 0l2.493-5.032H58.079V10.298h-6.312v21.84h18.551zm23.47-8.444h-8.181l4.137-8.747 4.044 8.747zm54.909 0h-8.194l4.137-8.747 4.057 8.747zm27.753 3.109c.239.777 1.167 1.317 2.188 1.317h6.312l4.681-12.857 4.442 16.888H200l-4.906-19.364c-.464-1.936-1.565-2.476-3.355-2.476h-6.166l-4.442 12.778-3.514-10.854c-.464-1.383-1.326-1.936-2.731-1.936h-6.935l-5.689 21.84h6.007l4.363-16.887 3.818 11.553zm-23.629 5.335h6.63l-10.449-20.457c-.543-1.001-1.087-1.396-2.334-1.396H140.9l-11.005 21.854h6.709l1.949-4.031h12.319l1.949 4.031zm-54.976 0h6.71L94.106 11.694c-.544-1.001-1.087-1.396-2.347-1.396h-5.768l-10.992 21.84h6.696l1.949-4.031h12.319l1.883 4.031zM8.964 54.322l5.384-1.95c1.127-.408 1.127.487 1.127 1.831v2.463c0 1.989-.676 2.226-1.565 2.556L8.977 61v-6.679h-.013zm0-10.907l13.684-4.953c1.127-.408 1.127.487 1.127 1.831v2.45c0 2.002-.676 2.239-1.565 2.569L8.977 50.093v-6.679h-.013zM7.85 27.857L.902 25.341C0 25.025 0 23.813 0 23.366c0 0 0 1.462 1.339.975l34.078-12.316c2.692-.975 2.692-1.726 1.565-2.134 1.127.408 1.127.408 1.127 3.675 0 3.122 0 4.163-1.339 4.65l-26.679 9.642c-1.127.408-1.127.408-2.241 0zm29.146-7.06c1.127.408 1.127 1.159-1.565 2.134L8.964 32.494v6.679L36.77 29.122c1.339-.487 1.339-1.528 1.339-4.65 0-3.267 0-3.267-1.114-3.675z" fill="#1b0088"/>
<g fill="#ed1650">
<path d="M2.917 15.159l11.908 4.308 9.242-3.346L1.79 8.072C.225 7.505 0 7.426 0 8.546v2.898c0 2.661 2.016 3.399 2.917 3.715zm34.079 5.638l-3.7-1.33-9.242 3.346 5.848 2.121 5.516-1.989c2.692-.988 2.692-1.739 1.578-2.147z"/>
<path d="M36.996 9.89L10.767.405C9.189-.161 8.964-.24 8.964.866v2.898c0 2.674 2.016 3.399 2.917 3.728l18.034 6.521 5.516-1.989c2.678-.975 2.678-1.726 1.565-2.134zM1.339 24.34l7.624-2.753-7.174-2.595C.225 18.426 0 18.334 0 19.453v3.912s0 1.462 1.339.975z"/>
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg26389" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="630" width="2670" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" viewBox="0 0 2670 630.00001">
<metadata id="metadata26394">
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g id="layer5" fill-rule="evenodd" transform="translate(580 -582.36)">
<g fill="#2a0088">
<path id="path27116" d="m1555.9 1052.4 80.21-310h88.682s22.491-2.1667 34.058 7 14.847 23.333 14.847 23.333l48.185 150.02 62.878-180.35h85.815s21.344 0 32.405 8.6667 14.386 21.667 14.386 21.667l70.344 279.67h-83.324l-62.85-238.38-65.966 181.72h-88.328s-10.643 1.0421-20.818-5.8333c-10.175-6.8755-10.495-13.167-10.495-13.167l-53.773-162.71-61.698 238.38z"/>
<path id="path27065" d="m729.66 742.36v61.834l148.82 10v238.17h85.953v-238.17l148.82-10v-61.834z"/>
<path id="path27022" d="m482.33 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.8041-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
<path id="path26992" d="m0 742.36v310h261.67l35.667-71.333h-207.67v-238.67z"/>
<path id="path27070" d="m1255.3 742.36-155 310h95.279l27.076-57h172.82l26.457 57h94.748l-147.05-289.67s-4.804-10.669-13.137-15.438c-8.3327-4.7684-17.102-4.8965-17.102-4.8965h-84.094zm52.625 66.943 56.961 123.06h-114.81l57.848-123.06z"/>
</g>
<g fill="#ed1651">
<path id="path27278" d="m-532.68 841.98 68.786-19.648s-0.73658-4.5943-3.2031-6.8493-6.9031-3.6493-6.9031-3.6493l-95.605-34.728s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v44.459s-0.2385 4.2567 5.927 13.798c6.1656 9.5409 20.77 7.111 20.77 7.111z"/>
<path id="path27260" d="m-258.85 734.71 63.986-35.848s0.96342-6.1943-1.5031-8.4493-5.4031-2.9493-5.4031-2.9493l-276.34-98.614s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
<path id="path27258" d="m-258.85 846.29 63.986-33.148s1.3634-8.3943-1.1031-10.649c-2.4665-2.255-5.8031-3.4493-5.8031-3.4493l-367.84-133.61s-3.0352-1.0205-5.2527 0.2078-2.1388 4.4193-2.1388 4.4193v38.333s-0.0385 8.882 6.127 18.423c6.1656 9.5409 16.17 12.011 16.17 12.011z"/>
</g>
<g fill="#2a0088">
<path id="path27194" d="m-201.77 687.46s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31s-1.328 0.40995-3.0557 0.40995-3.0557-0.40995-3.0557-0.40995l-76.943-27.949s-1.9088-0.44406-4.6171-4.6869c-3.05-4.76-2.83-14.21-2.83-14.21s0.47846 4.8448 3.217 7.1455 10.037-0.22335 10.037-0.22335l353.41-128.37s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27140" d="m-201.77 799.04s3.7354 0.56621 6.9031 3.6493 3.2031 6.8493 3.2031 6.8493v50.754s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.7111-7.0096 7.7111l-8.6667 3.1481-276.16 100.31v-68l276.16-100.31s14.033-5.7246 14.033-11.541c0-4.0162-5.4667-5.7407-5.4667-5.7407z"/>
<path id="path27142" d="m-338.86 977.68c2.6431 2.6431 2.6431 8.4596 2.6431 8.4596v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-131.61 47.807v-68l131.61-47.807s13.387-5.0845 16.03-2.4414z"/>
<path id="path27168" d="m-423.76 1119.6c2.6431 2.6431 2.6431 8.4595 2.6431 8.4595v37.953s-0.15239 6.9342-2.9966 13.17c-2.8442 6.2359-7.0096 7.711-7.0096 7.711l-8.6667 3.1482-46.71 17.807v-68l46.71-17.807s13.387-5.0845 16.03-2.4414z"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

855
static/world-map.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 83 KiB