Compare commits
8 Commits
ai-agent/4
...
7a6bf84eb5
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a6bf84eb5 | |||
| c12b21ae8a | |||
| 94fbd017cb | |||
| ebc8f80bb5 | |||
| 4aabf47108 | |||
| 2003a1aada | |||
| ae39f7961a | |||
| d43889c7b0 |
@@ -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
@@ -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 });
|
||||
});
|
||||
9
e2e/evidence-workflow.test.ts
Normal 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();
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 (?, ?, ?, ?, ?)`,
|
||||
|
||||
79
e2e/smoke-cross-role.test.ts
Normal 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();
|
||||
});
|
||||
70
e2e/trip-date-view.test.ts
Normal 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
@@ -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();
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
66
src/lib/components/TourBadge.svelte
Normal 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>
|
||||
109
src/lib/components/TourTimelineCard.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
34
src/lib/date-views.test.ts
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
}
|
||||
13
src/routes/(protected)/dashboard/+page.server.ts
Normal 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)
|
||||
};
|
||||
};
|
||||
@@ -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}. Here’s 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 & future routes</h2>
|
||||
<p class="mt-2 text-sm text-slate-200/80">
|
||||
Pins highlight places from your journey log and what’s 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 you’ve 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>
|
||||
|
||||
33
static/airline-logos/4C.svg
Normal 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 |
33
static/airline-logos/JJ.svg
Normal 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 |
@@ -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 |
33
static/airline-logos/LP.svg
Normal 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 |
@@ -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 |
@@ -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
|
After Width: | Height: | Size: 83 KiB |