Cleanup ui layout (#835)

This commit is contained in:
Michael Geers 2021-04-25 19:01:12 +02:00 • committed by GitHub
parent 5681d1996b
commit fb4dde7ce6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 564 additions and 543 deletions

View file

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="de">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />

View file

@ -5,6 +5,7 @@ export default {
component: Loadpoint,
argTypes: {
mode: { control: { type: "inline-radio", options: ["off", "now", "minpv", "pv"] } },
remoteDisabled: { control: { type: "radio", options: ["", "soft", "hard"] } },
climater: { control: { type: "inline-radio", options: ["on", "heating", "cooling"] } },
},
};
@ -19,11 +20,29 @@ export const Base = Template.bind({});
Base.args = {
id: 0,
pvConfigured: true,
chargePower: 2800,
chargedEnergy: 11e3,
chargeDuration: 95 * 60,
hasVehicle: true,
socTitle: "Mein Auto",
enabled: true,
connected: true,
charging: true,
socCharge: 66,
targetSoC: 90,
};
export const WithLevels = Template.bind({});
WithLevels.args = {
export const Disabled = Template.bind({});
Disabled.args = {
id: 0,
pvConfigured: true,
socLevels: [20, 50, 80, 100],
remoteDisabled: "soft",
remoteDisabledSource: "Sunny Home Manager",
hasVehicle: true,
socTitle: "Mein Auto",
enabled: true,
connected: true,
charging: false,
socCharge: 66,
targetSoC: 100,
};

View file

@ -1,143 +1,32 @@
<template>
<div>
<div class="row" v-if="multi">
<div class="col-12 col-md-4 d-md-flex mt-3 mt-md-5 align-items-end">
<span class="h1 align-bottom">{{ title || "Ladepunkt" }}</span>
</div>
<div class="col-12 col-md-8 d-none d-md-block mt-3 mt-md-5">
<LoadpointDetails v-bind="details"> </LoadpointDetails>
</div>
<div class="col-12 d-md-none">
<div class="row mt-3 pb-3 bg-light">
<div class="col-12 mt-3">
<Mode
class="w-100"
:mode="mode"
:pvConfigured="pvConfigured"
v-on:updated="setTargetMode"
></Mode>
</div>
<div class="col-12 mt-3" v-if="hasTargetSoC">
<Soc
class="w-100"
:soc="targetSoC"
:levels="socLevels"
v-on:updated="setTargetSoC"
></Soc>
</div>
</div>
</div>
<p class="h3 mb-4 d-sm-block" :class="{ 'd-none': single }">{{ title || "Ladepunkt" }}</p>
<div class="alert alert-warning mt-4 mb-2" role="alert" v-if="remoteDisabled == 'soft'">
{{ remoteDisabledSource }}: Adaptives PV-Laden deaktiviert
</div>
<div class="row d-none d-md-flex mt-5 py-3 pb-4 text-center bg-light" v-if="!multi">
<div class="mt-3" :class="{ 'col-md-6': hasTargetSoC, 'col-md-12': !hasTargetSoC }">
<Mode
:mode="mode"
:pvConfigured="pvConfigured"
:caption="true"
v-on:updated="setTargetMode"
></Mode>
</div>
<div class="col-md-6 mt-3" v-if="hasTargetSoC">
<Soc
:soc="targetSoC"
:levels="socLevels"
:caption="true"
v-on:updated="setTargetSoC"
></Soc>
<!-- <div class="btn-group btn-group-toggle bg-white shadow-none">
<label class="btn btn-outline-primary">
<input
type="checkbox"
class="disabled"
v-on:click="alert('not implemented - use api')"
/>
<fa-icon
icon="clock"
v-bind:class="{ fas: timerActive, far: !timerActive }"
></fa-icon>
</label>
</div> -->
</div>
<div class="alert alert-danger mt-4 mb-2" role="alert" v-if="remoteDisabled == 'hard'">
{{ remoteDisabledSource }}: Deaktiviert
</div>
<div class="row d-md-none mt-2 pb-3 bg-light" v-if="!multi">
<div class="col-12 mt-3">
<Mode
class="w-100"
:mode="mode"
:pvConfigured="pvConfigured"
v-on:updated="setTargetMode"
></Mode>
</div>
<div class="col-12 mt-3" v-if="hasTargetSoC">
<Soc
class="w-100"
:soc="targetSoC"
:levels="socLevels"
v-on:updated="setTargetSoC"
></Soc>
</div>
</div>
<div class="row" v-if="!multi">
<div class="col-12 col-md-4 d-none d-md-flex mt-3 mt-md-5">
<span class="h1">{{ title || "Ladepunkt" }}</span>
</div>
<div class="col-12 col-md-8 d-flex d-md-flex mt-3 mt-md-5 pt-3" v-if="remoteDisabled">
<h5 class="w-100">
<span class="badge badge-warning w-100" v-if="remoteDisabled == 'soft'">
{{ remoteDisabledSource }}: Adaptives PV-Laden deaktiviert
</span>
<span class="badge badge-danger w-100" v-if="remoteDisabled == 'hard'">
{{ remoteDisabledSource }}: Deaktiviert
</span>
</h5>
</div>
</div>
<div class="row border-bottom d-none d-md-block"></div>
<div class="row">
<div class="col-12 col-md-4 mt-3 mb-3 mb-md-0">
<Vehicle v-bind="vehicle"></Vehicle>
</div>
<div class="col-12 col-md-4 d-none d-md-block mt-3" v-if="multi">
<div class="mb-2 pb-1">Modus</div>
<Mode
class="btn-group-sm"
:mode="mode"
:pvConfigured="pvConfigured"
v-on:updated="setTargetMode"
></Mode>
</div>
<div class="col-12 col-md-4 d-none d-md-block mt-3" v-if="multi && hasTargetSoC">
<div class="mb-2 pb-1">Ladeziel</div>
<Soc
class="btn-group-sm"
:soc="targetSoC"
:levels="socLevels"
v-on:updated="setTargetSoC"
></Soc>
</div>
<div class="col-md-8 d-none d-md-block" v-if="!multi">
<LoadpointDetails v-bind="details"></LoadpointDetails>
</div>
<div class="col-12 d-md-none">
<LoadpointDetails v-bind="details"></LoadpointDetails>
</div>
<Mode
class="col-12 col-md-6 col-lg-4 mb-4"
:mode="mode"
:pvConfigured="pvConfigured"
v-on:updated="setTargetMode"
/>
<Vehicle
class="col-12 col-md-6 col-lg-8 mb-4"
v-bind="vehicle"
@target-soc-updated="setTargetSoC"
/>
</div>
<LoadpointDetails v-bind="details" />
</div>
</template>
<script>
import axios from "axios";
import Soc from "./Soc";
import Mode from "./Mode";
import Vehicle from "./Vehicle";
import LoadpointDetails from "./LoadpointDetails";
@ -148,14 +37,13 @@ export default {
name: "Loadpoint",
props: {
id: Number,
multi: Boolean,
pvConfigured: Boolean,
single: Boolean,
// main
title: String,
mode: String,
targetSoC: Number,
socLevels: Array,
remoteDisabled: Boolean,
remoteDisabledSource: String,
chargeDuration: Number,
@ -180,8 +68,20 @@ export default {
climater: String,
range: Number,
chargeEstimate: Number,
// other information
phases: Number,
minCurrent: Number,
maxCurrent: Number,
activePhases: Number,
chargeCurrent: Number,
socCapacity: Number,
connectedDuration: Number,
chargeCurrents: Object,
chargeConfigured: Boolean,
chargeRemainingEnergy: Number,
},
components: { LoadpointDetails, Soc, Mode, Vehicle },
components: { LoadpointDetails, Mode, Vehicle },
mixins: [formatter, collector],
data: function () {
return {
@ -196,9 +96,6 @@ export default {
vehicle: function () {
return this.collectProps(Vehicle);
},
hasTargetSoC: function () {
return this.socLevels != null && this.socLevels.length > 0;
},
},
watch: {
chargeDuration: function () {

View file

@ -1,59 +1,62 @@
<template>
<div class="row">
<div class="col-6 col-md-3 mt-3">
<div class="mb-2 value">
Leistung
<fa-icon
class="text-primary ml-1"
icon="temperature-low"
v-if="climater == 'heating'"
></fa-icon>
<fa-icon
class="text-primary ml-1"
icon="temperature-high"
v-if="climater == 'cooling'"
></fa-icon>
<fa-icon
class="text-primary ml-1"
icon="thermometer-half"
v-if="climater == 'on'"
></fa-icon>
<div>
<div class="row">
<div class="col-6 col-sm-3 col-lg-2 mt-3 offset-lg-4">
<div class="mb-2 value">
Leistung
<fa-icon
class="text-primary ml-1"
icon="temperature-low"
v-if="climater == 'heating'"
></fa-icon>
<fa-icon
class="text-primary ml-1"
icon="temperature-high"
v-if="climater == 'cooling'"
></fa-icon>
<fa-icon
class="text-primary ml-1"
icon="thermometer-half"
v-if="climater == 'on'"
></fa-icon>
</div>
<h3 class="value">
{{ fmt(chargePower) }}
<small class="text-muted">{{ fmtUnit(chargePower) }}W</small>
</h3>
</div>
<h2 class="value">
{{ fmt(chargePower) }}
<small class="text-muted">{{ fmtUnit(chargePower) }}W</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3">
<div class="mb-2 value">Geladen</div>
<h2 class="value">
{{ fmt(chargedEnergy) }}
<small class="text-muted">{{ fmtUnit(chargedEnergy) }}Wh</small>
</h2>
</div>
<div class="col-6 col-md-3 mt-3" v-if="range >= 0">
<div class="mb-2 value">Reichweite</div>
<h2 class="value">
{{ Math.round(range) }}
<small class="text-muted">km</small>
</h2>
</div>
<div class="col-6 col-sm-3 col-lg-2 mt-3">
<div class="mb-2 value">Geladen</div>
<h3 class="value">
{{ fmt(chargedEnergy) }}
<small class="text-muted">{{ fmtUnit(chargedEnergy) }}Wh</small>
</h3>
</div>
<div class="col-6 col-md-3 mt-3" v-else>
<div class="mb-2 value">Dauer</div>
<h2 class="value">
{{ fmtShortDuration(chargeDuration) }}
<small class="text-muted">{{ fmtShortDurationUnit(chargeDuration) }}</small>
</h2>
</div>
<div class="col-6 col-sm-3 col-lg-2 mt-3" v-if="range >= 0">
<div class="mb-2 value">Reichweite</div>
<h3 class="value">
{{ Math.round(range) }}
<small class="text-muted">km</small>
</h3>
</div>
<div class="col-6 col-md-3 mt-3" v-if="hasVehicle">
<div class="mb-2 value">Restzeit</div>
<h2 class="value">
{{ fmtShortDuration(chargeEstimate) }}
<small class="text-muted">{{ fmtShortDurationUnit(chargeEstimate) }}</small>
</h2>
<div class="col-6 col-sm-3 col-lg-2 mt-3" v-else>
<div class="mb-2 value">Dauer</div>
<h3 class="value">
{{ fmtShortDuration(chargeDuration) }}
<small class="text-muted">{{ fmtShortDurationUnit(chargeDuration) }}</small>
</h3>
</div>
<div class="col-6 col-sm-3 col-lg-2 mt-3" v-if="hasVehicle">
<div class="mb-2 value">Restzeit</div>
<h3 class="value">
{{ fmtShortDuration(chargeEstimate) }}
<small class="text-muted">{{ fmtShortDurationUnit(chargeEstimate) }}</small>
</h3>
</div>
</div>
</div>
</template>

View file

@ -1,35 +1,32 @@
<template>
<div class="btn-group btn-group-toggle bg-white shadow-none">
<label
class="btn btn-outline-primary value disabled caption font-weight-bold"
v-if="caption"
>
Modus
</label>
<label class="btn btn-outline-primary" :class="{ active: mode == 'off', first: !caption }">
<input type="radio" value="off" v-on:click="setTargetMode('off')" />Stop
</label>
<label class="btn btn-outline-primary" :class="{ active: mode == 'now' }">
<input type="radio" value="now" v-on:click="setTargetMode('now')" />Sofort
</label>
<label
class="btn btn-outline-primary"
:class="{ active: mode == 'minpv' }"
v-if="pvConfigured"
>
<input type="radio" value="minpv" v-on:click="setTargetMode('minpv')" />
<span class="d-inline d-lg-none">Min</span>
<span class="d-none d-lg-inline">Min + PV</span>
</label>
<label
class="btn btn-outline-primary"
:class="{ active: mode == 'pv' }"
v-if="pvConfigured"
>
<input type="radio" value="pv" v-on:click="setTargetMode('pv')" />
<span class="d-inline d-md-none">PV</span>
<span class="d-none d-md-inline">Nur PV</span>
</label>
<div>
<div class="mb-3">Modus</div>
<div class="btn-group btn-group-toggle bg-white shadow-none w-100">
<label class="btn btn-outline-primary first" :class="{ active: mode == 'off' }">
<input type="radio" value="off" @click="setTargetMode('off')" />Stop
</label>
<label class="btn btn-outline-primary" :class="{ active: mode == 'now' }">
<input type="radio" value="now" @click="setTargetMode('now')" />Sofort
</label>
<label
class="btn btn-outline-primary"
:class="{ active: mode == 'minpv' }"
v-if="pvConfigured"
>
<input type="radio" value="minpv" @click="setTargetMode('minpv')" />
<span class="d-inline d-sm-none">Min</span>
<span class="d-none d-sm-inline">Min + PV</span>
</label>
<label
class="btn btn-outline-primary"
:class="{ active: mode == 'pv' }"
v-if="pvConfigured"
>
<input type="radio" value="pv" @click="setTargetMode('pv')" />
<span class="d-inline d-sm-none">PV</span>
<span class="d-none d-sm-inline">Nur PV</span>
</label>
</div>
</div>
</template>
@ -39,7 +36,6 @@ export default {
props: {
mode: String,
pvConfigured: Boolean,
caption: Boolean,
},
methods: {
setTargetMode: function (mode) {
@ -48,3 +44,10 @@ export default {
},
};
</script>
<style scoped>
.btn {
/* equal width buttons */
flex-basis: 0;
}
</style>

View file

@ -36,7 +36,19 @@ Single.args = {
loadpoints: [
{
title: "Ladepunkt 1",
socLevels: [20, 50, 80, 100],
mode: "now",
socTitle: "Mein Auto",
enabled: true,
connected: true,
hasVehicle: true,
charging: true,
socCharge: 66,
targetSoC: 90,
range: 344,
chargeEstimate: 999,
chargePower: 11232,
chargeDuration: 123982,
chargedEnergy: 23213,
},
],
};
@ -45,18 +57,31 @@ export const Multi = Template.bind({});
Multi.args = {
gridConfigured: true,
pvConfigured: true,
gridPower: 100,
gridPower: -100,
pvPower: 100,
batteryConfigured: true,
batteryPower: 100,
batterySoC: 0,
batterySoC: 60,
loadpoints: [
{
title: "Ladepunkt 1",
socLevels: [20, 50, 80, 100],
mode: "now",
socTitle: "Mein Auto",
enabled: true,
connected: true,
hasVehicle: true,
charging: true,
socCharge: 66,
targetSoC: 90,
range: 344,
chargeEstimate: 999,
chargePower: 11232,
chargeDuration: 123982,
chargedEnergy: 23213,
},
{
title: "Ladepunkt 2",
mode: "pv",
},
],
};

View file

@ -1,32 +1,26 @@
<template>
<div>
<div class="row">
<div class="d-none d-md-flex col-12 col-md-4 mt-md-4 align-items-end">
<div class="flex-grow-1 d-flex flex-column">
<div class="row mt-4 pt-2">
<div class="d-none d-md-flex col-12 col-md-3 col-lg-4 align-items-end">
<p class="h1">{{ siteTitle || "Home" }}</p>
</div>
<div class="col-12 col-md-8 mt-md-4" v-if="multi">
<div class="col-12 col-md-9 col-lg-6 flex-grow-1">
<SiteDetails v-bind="details"></SiteDetails>
</div>
</div>
<div class="row d-none d-md-flex border-bottom"></div>
<div class="row" v-if="!multi">
<div class="d-none d-md-block col-md-4"></div>
<div class="col-12 col-md-8">
<SiteDetails v-bind="details"></SiteDetails>
</div>
<hr class="w-100 my-4" />
<div class="flex-grow-1 d-flex justify-content-around flex-column">
<template v-for="(loadpoint, id) in loadpoints">
<hr class="w-100 my-4" v-if="id > 0" :key="id + '_hr'" />
<Loadpoint
:key="loadpoint"
v-bind="loadpoint"
:single="loadpoints.length === 1"
:id="id"
:pvConfigured="pvConfigured"
/>
</template>
</div>
<Loadpoint
v-for="(loadpoint, id) in loadpoints"
v-bind="loadpoint"
:id="id"
:key="id"
:multi="multi"
:pvConfigured="pvConfigured"
>
</Loadpoint>
</div>
</template>
@ -50,6 +44,8 @@ export default {
batteryConfigured: Boolean,
batteryPower: Number,
batterySoC: Number,
gridCurrents: Object,
prioritySoC: Number,
},
components: { SiteDetails, Loadpoint },
mixins: [formatter, collector],
@ -57,10 +53,6 @@ export default {
details: function () {
return this.collectProps(SiteDetails);
},
multi: function () {
// TODO fix compact
return this.loadpoints.length > 1 /* || app.compact*/;
},
},
};
</script>

View file

@ -1,18 +1,19 @@
<template>
<div class="row">
<div class="col-6 col-md-3 mt-3" v-if="gridConfigured">
<div class="row row-cols-3 justify-content-between justify-content-md-start overflow-hidden">
<div class="px-3" v-if="gridConfigured">
<div class="mb-2 value" v-if="gridPower > 0">
Bezug <fa-icon icon="arrow-down" class="text-primary" />
</div>
<div class="mb-2 value" v-else>
Einspeisung <fa-icon icon="arrow-up" class="text-primary"></fa-icon>
</div>
<h2 class="value">
<h3 class="value">
{{ fmt(gridPower) }}
<small class="text-muted">{{ fmtUnit(gridPower) }}W</small>
</h2>
</h3>
</div>
<div class="col-6 col-md-3 mt-3" v-if="pvConfigured">
<div class="px-3" v-if="pvConfigured">
<div class="mb-2 value">
Erzeugung
<fa-icon
@ -23,22 +24,25 @@
}"
></fa-icon>
</div>
<h2 class="value">
<h3 class="value">
{{ fmt(pvPower) }}
<small class="text-muted">{{ fmtUnit(pvPower) }}W</small>
</h2>
</h3>
</div>
<div class="d-md-block col-6 col-md-3 mt-3" v-if="batteryConfigured">
<div class="px-3" v-if="batteryConfigured">
<div class="mb-2 value">
Batterie
<fa-icon class="text-primary" :icon="batteryIcon"></fa-icon>
<div class="d-block d-sm-none">
Akku <span class="text-muted"> / {{ batterySoC }} %</span>
</div>
<div class="d-none d-sm-block">
Batterie <span class="text-muted"> / {{ batterySoC }}% </span>
<fa-icon class="text-primary" :icon="batteryIcon"></fa-icon>
</div>
</div>
<h2 class="value">
<h3 class="value">
{{ fmt(batteryPower) }}
<small class="text-muted">{{ fmtUnit(batteryPower) }}W</small>
<small class="text-muted">/</small>
{{ batterySoC }} <small class="text-muted">%</small>
</h2>
</h3>
</div>
</div>
</template>
@ -74,6 +78,13 @@ export default {
},
mixins: [formatter],
computed: {
numberOfPanels: function () {
let count = 0;
if (this.gridConfigured) count++;
if (this.pvConfigured) count++;
if (this.batteryConfigured) count++;
return count;
},
batteryIcon: function () {
if (Math.abs(this.batteryPower) < limit) {
if (this.batterySoC < 30) return icons[0];

View file

@ -1,26 +0,0 @@
import Soc from "./Soc.vue";
export default {
title: "Main/Soc",
component: Soc,
argTypes: {},
};
const Template = (args, { argTypes }) => ({
props: Object.keys(argTypes),
components: { Soc },
template: '<Soc v-bind="$props"></Soc>',
});
export const Base = Template.bind({});
Base.args = {
soc: 80,
levels: [20, 50, 80, 100],
};
export const Caption = Template.bind({});
Caption.args = {
caption: true,
soc: 80,
levels: [20, 50, 80, 100],
};

View file

@ -1,41 +0,0 @@
<template>
<div class="btn-group btn-group-toggle bg-white shadow-none">
<label class="btn btn-outline-primary disabled caption font-weight-bold" v-if="caption">
Ladeziel
</label>
<label
class="btn btn-outline-primary"
v-for="(level, id) in levelsOrDefault"
:key="id"
:level="level"
:id="id"
:class="{ active: soc == level, first: !caption && id == 0 }"
>
<input type="radio" :value="level" v-on:click="setTargetSoC(level)" />{{ level }}%
</label>
</div>
</template>
<script>
export default {
name: "Soc",
props: {
soc: Number,
caption: Boolean,
levels: Array,
},
computed: {
levelsOrDefault: function () {
if (this.levels == null || this.levels.length == 0) {
return []; // disabled, or use 30, 50, 80, 100
}
return this.levels;
},
},
methods: {
setTargetSoC: function (mode) {
this.$emit("updated", mode);
},
},
};
</script>

View file

@ -30,6 +30,7 @@ Connected.args = {
hasVehicle: true,
charging: false,
socCharge: 66,
targetSoC: 90,
};
export const ReadyToCharge = Template.bind({});
@ -40,6 +41,7 @@ ReadyToCharge.args = {
hasVehicle: true,
charging: false,
socCharge: 66,
targetSoC: 90,
};
export const Charging = Template.bind({});
@ -50,6 +52,7 @@ Charging.args = {
hasVehicle: true,
charging: true,
socCharge: 66,
targetSoC: 90,
};
const hoursFromNow = function (hours) {
@ -97,6 +100,7 @@ MinCharge.args = {
socCharge: 17,
minSoC: 20,
charging: true,
targetSoC: 90,
};
export const UnknownVehicleConnected = Template.bind({});

View file

@ -1,189 +1,48 @@
<template>
<div>
<div class="mb-2 pb-1">
<div class="mb-3">
{{ socTitle || "Fahrzeug" }}
</div>
<div class="soc-bar">
<div class="progress small">
<div
class="progress-bar"
role="progressbar"
:class="{
'progress-bar-striped': charging,
'progress-bar-animated': charging,
[progressColor]: true,
}"
:style="{ width: `${socChargeDisplayWidth}%` }"
>
{{ socChargeDisplayValue }}
</div>
<div
v-if="remainingSoCWidth > 0"
class="progress-bar"
role="progressbar"
:class="{
'progress-bar-striped': charging,
'progress-bar-animated': charging,
[progressColor]: true,
'bg-muted': true,
}"
:style="{ width: `${remainingSoCWidth}%` }"
></div>
<div
v-if="socMarker"
class="soc-marker"
:class="{ [progressColor]: true }"
:style="{ left: `${socMarker}%` }"
></div>
</div>
</div>
<small v-if="markerLabel()" class="subline my-2 text-secondary">
<fa-icon
v-if="minSoCActive"
class="text-muted mr-1"
icon="exclamation-circle"
></fa-icon>
<fa-icon v-else-if="targetChargeEnabled" class="text-muted mr-1" icon="clock"></fa-icon>
{{ markerLabel() }}
</small>
<VehicleSoc v-bind="vehicleSoc" @target-soc-updated="targetSocUpdated" />
<VehicleSubline v-bind="vehicleSubline" class="my-1" />
</div>
</template>
<script>
import formatter from "../mixins/formatter";
import collector from "../mixins/collector";
import VehicleSoc from "./VehicleSoc";
import VehicleSubline from "./VehicleSubline";
export default {
name: "Vehicle",
components: { VehicleSoc, VehicleSubline },
props: {
socTitle: String,
connected: Boolean,
hasVehicle: Boolean,
socCharge: Number,
enabled: Boolean,
charging: Boolean,
minSoC: Number,
socTitle: String,
timerActive: Boolean,
timerSet: Boolean,
targetTime: String,
targetSoC: Number,
},
computed: {
socChargeDisplayWidth: function () {
if (this.hasVehicle && this.socCharge >= 0) {
return this.socCharge;
}
return 100;
vehicleSoc: function () {
return this.collectProps(VehicleSoc);
},
socChargeDisplayValue: function () {
// no soc or no soc value
if (!this.hasVehicle || !this.socCharge || this.socCharge < 0) {
let chargeStatus = "getrennt";
if (this.charging) {
chargeStatus = "lädt";
} else if (this.enabled) {
chargeStatus = "bereit";
} else if (this.connected) {
chargeStatus = "verbunden";
}
return chargeStatus;
}
// percent value if enough space
let socCharge = this.socCharge;
if (socCharge >= 10) {
socCharge += "%";
}
return socCharge;
},
socMarker: function () {
if (this.minSoCActive) {
return this.minSoC;
}
if (this.targetSoC > this.socCharge) {
return this.targetSoC;
}
return null;
},
progressColor: function () {
if (!this.connected) {
return "bg-light border";
}
if (this.minSoCActive) {
return "bg-danger";
}
if (this.enabled) {
return "bg-primary";
}
return "bg-secondary";
},
minSoCActive: function () {
return this.minSoC > 0 && this.socCharge < this.minSoC;
},
targetChargeEnabled: function () {
return this.targetTime && this.timerSet;
},
remainingSoCWidth: function () {
if (this.socCharge === 100) {
return null;
}
if (this.minSoCActive) {
return this.minSoC - this.socCharge;
}
if (this.targetSoC > this.socCharge) {
return this.targetSoC - this.socCharge;
}
return null;
vehicleSubline: function () {
return this.collectProps(VehicleSubline);
},
},
methods: {
// not computed because it needs to update over time
markerLabel: function () {
if (!this.connected) {
return null;
}
if (this.minSoCActive) {
return `Mindestladung bis ${this.socMarker}%`;
}
if (this.targetChargeEnabled) {
const targetDate = Date.parse(this.targetTime);
if (this.timerActive) {
return `Lädt ${this.fmtRelativeTime(targetDate)} bis ${this.socMarker}%`;
} else {
return `Geplant bis ${this.fmtAbsoluteDate(targetDate)} bis ${this.socMarker}%`;
}
}
return null;
targetSocUpdated: function (targetSoC) {
this.$emit("target-soc-updated", targetSoC);
},
},
mixins: [formatter],
mixins: [collector],
};
</script>
<style scoped>
.subline {
display: flex;
align-items: center;
}
.soc-bar {
position: relative;
height: 31px;
}
.progress {
height: 100%;
font-size: 0.875rem;
}
.progress-bar.bg-muted {
color: var(--white);
}
.soc-marker {
position: absolute;
top: -2px;
bottom: -2px;
width: 2px;
}
.bg-disabled {
background-color: var(--gray);
}
.bg-light {
color: var(--dark);
}
</style>

View file

@ -0,0 +1,251 @@
<template>
<div class="vehicle-soc">
<div class="progress">
<div
class="progress-bar"
role="progressbar"
:class="{
'progress-bar-striped': charging,
'progress-bar-animated': charging,
[progressColor]: true,
}"
:style="{ width: `${socChargeDisplayWidth}%` }"
>
{{ socChargeDisplayValue }}
</div>
<div
v-if="remainingSoCWidth > 0"
class="progress-bar"
role="progressbar"
:class="{
[progressColor]: true,
'bg-muted': true,
}"
:style="{ width: `${remainingSoCWidth}%`, transition: 'none' }"
></div>
</div>
<div
v-if="hasVehicle && visibleTargetSoC"
class="target"
:class="{ 'target--max': visibleTargetSoC === 100 }"
>
<div
class="target-label d-flex align-items-center justify-content-center"
:style="{ left: `${visibleTargetSoC}%` }"
>
{{ visibleTargetSoC }}%
</div>
<input
type="range"
min="0"
max="100"
step="5"
:value="visibleTargetSoC"
class="target-slider"
@input="movedTargetSoC"
@change="setTargetSoC"
/>
</div>
</div>
</template>
<script>
export default {
name: "VehicleSoc",
props: {
connected: Boolean,
hasVehicle: Boolean,
socCharge: Number,
enabled: Boolean,
charging: Boolean,
minSoC: Number,
targetSoC: Number,
},
data: function () {
return {
selectedTargetSoC: null,
};
},
computed: {
socChargeDisplayWidth: function () {
if (this.hasVehicle && this.socCharge >= 0) {
return this.socCharge;
}
return 100;
},
socChargeDisplayValue: function () {
// no soc or no soc value
if (!this.hasVehicle || !this.socCharge || this.socCharge < 0) {
let chargeStatus = "getrennt";
if (this.charging) {
chargeStatus = "lädt";
} else if (this.enabled) {
chargeStatus = "bereit";
} else if (this.connected) {
chargeStatus = "verbunden";
}
return chargeStatus;
}
// percent value if enough space
let socCharge = this.socCharge;
if (socCharge >= 10) {
socCharge += "%";
}
return socCharge;
},
progressColor: function () {
if (!this.connected) {
return "bg-light border";
}
if (this.minSoCActive) {
return "bg-danger";
}
if (this.enabled) {
return "bg-primary";
}
return "bg-secondary";
},
minSoCActive: function () {
return this.minSoC > 0 && this.socCharge < this.minSoC;
},
remainingSoCWidth: function () {
if (this.socChargeDisplayWidth === 100) {
return null;
}
if (this.minSoCActive) {
return this.minSoC - this.socCharge;
}
if (this.visibleTargetSoC > this.socCharge) {
return this.visibleTargetSoC - this.socCharge;
}
return null;
},
visibleTargetSoC: function () {
return Number(this.selectedTargetSoC || this.targetSoC);
},
},
methods: {
movedTargetSoC: function (e) {
const minTargetSoC = 40;
if (e.target.value < minTargetSoC) {
e.target.value = minTargetSoC;
this.selectedTargetSoC = e.target.value;
e.preventDefault();
return false;
}
this.selectedTargetSoC = e.target.value;
return true;
},
setTargetSoC: function (e) {
this.$emit("target-soc-updated", e.target.value);
},
},
};
</script>
<style scoped>
.vehicle-soc {
--height: 38px;
--thumb-overlap: 3px;
--thumb-width: 3px;
--thumb-horizontal-padding: 15px;
--label-height: 26px;
position: relative;
height: var(--height);
}
.progress {
height: 100%;
font-size: 1rem;
}
.progress-bar.bg-muted {
color: var(--white);
}
.bg-light {
color: var(--dark);
}
.target-label {
width: 3em;
margin-left: -1.5em;
height: var(--label-height);
position: absolute;
top: calc((var(--label-height) + var(--thumb-overlap)) * -1);
text-align: center;
color: var(--dark);
font-size: 1rem;
opacity: 1;
transition: opacity 0.2s ease 1s;
}
.target-slider {
-webkit-appearance: none;
position: absolute;
top: calc(var(--thumb-overlap) * -1);
left: calc(var(--thumb-horizontal-padding) * -1);
height: calc(100% + 2 * var(--thumb-overlap));
width: calc(100% + 2 * var(--thumb-horizontal-padding));
background: transparent;
}
.target-slider:focus {
outline: none;
}
/* Note: Safari,Chrome,Blink and Firefox specific styles need to be in separate definitions to work */
.target-slider::-webkit-slider-runnable-track {
background: transparent;
border: none;
height: 100%;
cursor: pointer;
}
.target-slider::-moz-range-track {
background: transparent;
border: none;
height: 100%;
cursor: pointer;
}
.target-slider::-webkit-slider-thumb {
-webkit-appearance: none;
position: relative;
top: calc(var(--label-height) * -1);
height: 100%;
width: var(--thumb-width);
padding: var(--label-height) var(--thumb-horizontal-padding) 0;
box-sizing: content-box;
background-clip: content-box;
background-color: var(--dark);
cursor: grab;
border: none;
opacity: 1;
transition: opacity 0.2s ease 1s;
}
.target-slider::-moz-range-thumb {
position: relative;
top: calc(var(--label-height) * -1);
height: 100%;
width: var(--thumb-width);
padding: var(--label-height) var(--thumb-horizontal-padding) 0;
box-sizing: content-box;
background-clip: content-box;
background-color: var(--dark);
cursor: grab;
border: none;
opacity: 1;
transition: opacity 0.2s ease 1s;
}
/* auto-hide targetSoC marker at 100% */
.target--max .target-slider::-webkit-slider-thumb,
.target--max .target-label {
opacity: 0;
}
.target--max .target-slider::-moz-range-thumb,
.target--max .target-label {
opacity: 0;
}
.target:hover .target-slider::-webkit-slider-thumb,
.target:hover .target-label {
opacity: 1;
transition-delay: 0s;
}
.target:hover .target-slider::-moz-range-thumb,
.target:hover .target-label {
opacity: 1;
transition-delay: 0s;
}
</style>

View file

@ -0,0 +1,53 @@
<template>
<div class="d-flex justify-content-between align-items-center">
<small class="text-secondary">
<span v-if="minSoCActive">
<fa-icon class="text-muted mr-1" icon="exclamation-circle"></fa-icon>
Mindestladung bis {{ minSoC }}%
</span>
</small>
<small
v-if="targetChargeEnabled"
:class="{
invisible: !targetSoC,
'text-primary': timerActive,
'text-secondary': !timerActive,
}"
>
{{ targetTimeLabel() }}
<fa-icon class="ml-1" icon="clock"></fa-icon>
</small>
</div>
</template>
<script>
import formatter from "../mixins/formatter";
export default {
name: "VehicleSubline",
props: {
socCharge: Number,
minSoC: Number,
timerActive: Boolean,
timerSet: Boolean,
targetTime: String,
targetSoC: Number,
},
computed: {
minSoCActive: function () {
return this.minSoC > 0 && this.socCharge < this.minSoC;
},
targetChargeEnabled: function () {
return this.targetTime && this.timerSet;
},
},
methods: {
// not computed because it needs to update over time
targetTimeLabel: function () {
const targetDate = new Date(this.targetTime);
return `bis ${this.fmtAbsoluteDate(targetDate)} Uhr`;
},
},
mixins: [formatter],
};
</script>

View file

@ -58,34 +58,23 @@ export default {
}
return "m";
},
fmtDayString: function (date) {
const YY = `${date.getFullYear()}`;
const MM = `${date.getMonth() + 1}`.padStart(2, "0");
const DD = `${date.getDate()}`.padStart(2, "0");
return `${YY}-${MM}-${DD}`;
},
fmtTimeString: function (date) {
const HH = `${date.getHours()}`.padStart(2, "0");
const mm = `${date.getMinutes()}`.padStart(2, "0");
return `${HH}:${mm}`;
},
fmtAbsoluteDate: function (date) {
return new Intl.DateTimeFormat("de-DE", {
return new Intl.DateTimeFormat("default", {
weekday: "short",
hour: "numeric",
minute: "numeric",
}).format(date);
},
fmtRelativeTime: function (date) {
var units = {
year: 24 * 60 * 60 * 1000 * 365,
month: (24 * 60 * 60 * 1000 * 365) / 12,
day: 24 * 60 * 60 * 1000,
hour: 60 * 60 * 1000,
minute: 60 * 1000,
second: 1000,
};
var rtf = new Intl.RelativeTimeFormat("de", { numeric: "auto", style: "narrow" });
var getRelativeTime = (d1, d2 = new Date()) => {
var elapsed = d1 - d2;
// "Math.abs" accounts for both "past" & "future" scenarios
for (var u in units)
if (Math.abs(elapsed) > units[u] || u == "second")
return rtf.format(Math.round(elapsed / units[u]), u);
};
return getRelativeTime(date);
},
},
};

View file

@ -1,6 +1,6 @@
<template>
<div class="app d-flex flex-column justify-content-between">
<div>
<div class="app d-flex flex-column justify-content-between overflow-hidden">
<div class="flex-grow-1 d-flex flex-column justify-content-between">
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="https://github.com/andig/evcc">
@ -32,7 +32,9 @@
</div>
</nav>
<router-view></router-view>
<router-view
class="flex-grow-1 d-flex flex-column justify-content-stretch"
></router-view>
</div>
<Footer :version="version"></Footer>
</div>

View file

@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"math"
"sort"
"strings"
"sync"
"time"
@ -42,7 +41,6 @@ type PollConfig struct {
type SoCConfig struct {
Poll PollConfig `mapstructure:"poll"`
AlwaysUpdate bool `mapstructure:"alwaysUpdate"`
Levels []int `mapstructure:"levels"`
Estimate bool `mapstructure:"estimate"`
Min int `mapstructure:"min"` // Default minimum SoC, guarded by mutex
Target int `mapstructure:"target"` // Default target SoC, guarded by mutex
@ -135,8 +133,6 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
lp.Mode = api.ChargeModeString(string(lp.Mode))
lp.OnDisconnect.Mode = api.ChargeModeString(string(lp.OnDisconnect.Mode))
sort.Ints(lp.SoC.Levels)
// set vehicle polling mode
switch lp.SoC.Poll.Mode = strings.ToLower(lp.SoC.Poll.Mode); lp.SoC.Poll.Mode {
case pollCharging:
@ -167,10 +163,6 @@ func NewLoadPointFromConfig(log *util.Logger, cp configProvider, other map[strin
if lp.SoC.Target == 0 {
lp.SoC.Target = 100
}
if len(lp.SoC.Levels) > 0 {
lp.SoC.Target = lp.SoC.Levels[len(lp.SoC.Levels)-1]
}
}
if lp.Meters.ChargeMeterRef != "" {
@ -411,7 +403,6 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
lp.publish("mode", lp.Mode)
lp.publish("targetSoC", lp.SoC.Target)
lp.publish("minSoC", lp.SoC.Min)
lp.publish("socLevels", lp.SoC.Levels)
lp.Unlock()
// use first vehicle for estimator

View file

@ -1 +0,0 @@
.progress-bar-animated{animation-direction:reverse}.btn.caption{opacity:1}.btn.first{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}.value,input[type=radio],label.btn{white-space:nowrap!important}.text-muted a,.text-muted a:hover{color:#6c757d!important;text-decoration:none}.bg-muted{opacity:.25}.fade-enter-active[data-v-a6ded36e],.fade-leave-active[data-v-a6ded36e]{transition:opacity .25s ease-in}.fade-enter[data-v-a6ded36e],.fade-leave-to[data-v-a6ded36e]{opacity:0}.dialog[data-v-a6ded36e]{position:fixed;top:0;left:0;z-index:1050;width:100%;height:100%;overflow:hidden;outline:0}.icon[data-v-a6ded36e]{color:#0fdd42}.text-black a[data-v-a6ded36e]{color:#18191a}.icon[data-v-5f0f38d1]{color:#0fdd42}.text-black a[data-v-5f0f38d1]{color:#18191a}.logo[data-v-459f11b2]{width:85px}.app[data-v-459f11b2]{min-height:100vh}.subline[data-v-b9f97a64]{display:flex;align-items:center}.soc-bar[data-v-b9f97a64]{position:relative;height:31px}.progress[data-v-b9f97a64]{height:100%;font-size:.875rem}.progress-bar.bg-muted[data-v-b9f97a64]{color:var(--white)}.soc-marker[data-v-b9f97a64]{position:absolute;top:-2px;bottom:-2px;width:2px}.bg-disabled[data-v-b9f97a64]{background-color:var(--gray)}.bg-light[data-v-b9f97a64]{color:var(--dark)}

1
dist/css/index.9b973d78.css vendored Normal file
View file

@ -0,0 +1 @@
.progress-bar-animated{animation-direction:reverse}.btn.caption{opacity:1}.btn.first{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}.value,input[type=radio],label.btn{white-space:nowrap!important}.text-muted a,.text-muted a:hover{color:#6c757d!important;text-decoration:none}.bg-muted{opacity:.25}.fade-enter-active[data-v-a6ded36e],.fade-leave-active[data-v-a6ded36e]{transition:opacity .25s ease-in}.fade-enter[data-v-a6ded36e],.fade-leave-to[data-v-a6ded36e]{opacity:0}.dialog[data-v-a6ded36e]{position:fixed;top:0;left:0;z-index:1050;width:100%;height:100%;overflow:hidden;outline:0}.icon[data-v-a6ded36e]{color:#0fdd42}.text-black a[data-v-a6ded36e]{color:#18191a}.icon[data-v-5f0f38d1]{color:#0fdd42}.text-black a[data-v-5f0f38d1]{color:#18191a}.logo[data-v-8dad761a]{width:85px}.app[data-v-8dad761a]{min-height:100vh}.btn[data-v-c8c77010]{flex-basis:0}.vehicle-soc[data-v-7f94c1fe]{--height:38px;--thumb-overlap:3px;--thumb-width:3px;--thumb-horizontal-padding:15px;--label-height:26px;position:relative;height:var(--height)}.progress[data-v-7f94c1fe]{height:100%;font-size:1rem}.progress-bar.bg-muted[data-v-7f94c1fe]{color:var(--white)}.bg-light[data-v-7f94c1fe]{color:var(--dark)}.target-label[data-v-7f94c1fe]{width:3em;margin-left:-1.5em;height:var(--label-height);position:absolute;top:calc(var(--label-height)*-1 + var(--thumb-overlap)*-1);text-align:center;color:var(--dark);font-size:1rem;opacity:1;transition:opacity .2s ease 1s}.target-slider[data-v-7f94c1fe]{-webkit-appearance:none;position:absolute;top:calc(var(--thumb-overlap)*-1);left:calc(var(--thumb-horizontal-padding)*-1);height:calc(100% + var(--thumb-overlap)*2);width:calc(100% + var(--thumb-horizontal-padding)*2);background:transparent}.target-slider[data-v-7f94c1fe]:focus{outline:none}.target-slider[data-v-7f94c1fe]::-webkit-slider-runnable-track{background:transparent;border:none;height:100%;cursor:pointer}.target-slider[data-v-7f94c1fe]::-moz-range-track{background:transparent;border:none;height:100%;cursor:pointer}.target-slider[data-v-7f94c1fe]::-webkit-slider-thumb{-webkit-appearance:none;position:relative;top:calc(var(--label-height)*-1);height:100%;width:var(--thumb-width);padding:var(--label-height) var(--thumb-horizontal-padding) 0;box-sizing:content-box;background-clip:content-box;background-color:var(--dark);cursor:-webkit-grab;cursor:grab;border:none;opacity:1;-webkit-transition:opacity .2s ease 1s;transition:opacity .2s ease 1s}.target-slider[data-v-7f94c1fe]::-moz-range-thumb{position:relative;top:calc(var(--label-height)*-1);height:100%;width:var(--thumb-width);padding:var(--label-height) var(--thumb-horizontal-padding) 0;box-sizing:content-box;background-clip:content-box;background-color:var(--dark);cursor:grab;border:none;opacity:1;-moz-transition:opacity .2s ease 1s;transition:opacity .2s ease 1s}.target--max .target-label[data-v-7f94c1fe],.target--max .target-slider[data-v-7f94c1fe]::-webkit-slider-thumb{opacity:0}.target--max .target-label[data-v-7f94c1fe],.target--max .target-slider[data-v-7f94c1fe]::-moz-range-thumb{opacity:0}.target:hover .target-label[data-v-7f94c1fe],.target:hover .target-slider[data-v-7f94c1fe]::-webkit-slider-thumb{opacity:1;transition-delay:0s}.target:hover .target-label[data-v-7f94c1fe],.target:hover .target-slider[data-v-7f94c1fe]::-moz-range-thumb{opacity:1;transition-delay:0s}

4
dist/index.html vendored
View file

@ -1,4 +1,4 @@
<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="EV Charge Controller"><meta name="author" content="andig"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]"><link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]"><link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]"><link rel="manifest" href="ico/site.webmanifest"><link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a"><link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]"><meta name="apple-mobile-web-app-title" content="evcc"><meta name="application-name" content="evcc"><meta name="msapplication-TileColor" content="#18191a"><meta name="msapplication-config" content="ico/browserconfig.xml"><meta name="theme-color" content="#18191a"><title>evcc</title><link href="css/chunk-vendors.52eaf30c.css" rel="preload" as="style"><link href="css/index.3b453e73.css" rel="preload" as="style"><link href="js/chunk-vendors.35669cb4.js" rel="preload" as="script"><link href="js/index.eb5cb609.js" rel="preload" as="script"><link href="css/chunk-vendors.52eaf30c.css" rel="stylesheet"><link href="css/index.3b453e73.css" rel="stylesheet"></head><body><script>window.evcc = {
<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="EV Charge Controller"><meta name="author" content="andig"><meta name="apple-mobile-web-app-capable" content="yes"><meta name="apple-mobile-web-app-status-bar-style" content="black"><link rel="apple-touch-icon" sizes="180x180" href="ico/apple-touch-icon.png?[[.Version]]"><link rel="icon" type="image/png" sizes="32x32" href="ico/favicon-32x32.png?[[.Version]]"><link rel="icon" type="image/png" sizes="16x16" href="ico/favicon-16x16.png?[[.Version]]"><link rel="manifest" href="ico/site.webmanifest"><link rel="mask-icon" href="ico/safari-pinned-tab.svg?[[.Version]]" color="#18191a"><link rel="shortcut icon" href="ico/favicon.ico?[[.Version]]"><meta name="apple-mobile-web-app-title" content="evcc"><meta name="application-name" content="evcc"><meta name="msapplication-TileColor" content="#18191a"><meta name="msapplication-config" content="ico/browserconfig.xml"><meta name="theme-color" content="#18191a"><title>evcc</title><link href="css/chunk-vendors.52eaf30c.css" rel="preload" as="style"><link href="css/index.9b973d78.css" rel="preload" as="style"><link href="js/chunk-vendors.35669cb4.js" rel="preload" as="script"><link href="js/index.9bd6ba1b.js" rel="preload" as="script"><link href="css/chunk-vendors.52eaf30c.css" rel="stylesheet"><link href="css/index.9b973d78.css" rel="stylesheet"></head><body><script>window.evcc = {
version: "[[.Version]]",
configured: "[[.Configured]]",
};</script><div id="app"></div><div id="toasts"></div><script src="js/chunk-vendors.35669cb4.js"></script><script src="js/index.eb5cb609.js"></script></body></html>
};</script><div id="app"></div><div id="toasts"></div><script src="js/chunk-vendors.35669cb4.js"></script><script src="js/index.9bd6ba1b.js"></script></body></html>

2
dist/js/index.9bd6ba1b.js vendored Normal file

File diff suppressed because one or more lines are too long

1
dist/js/index.9bd6ba1b.js.map vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -89,11 +89,6 @@ loadpoints:
min: 0 # immediately charge to 0% regardless of mode unless "off" (disabled)
target: 100 # always charge to 100%
estimate: false # set true to interpolate between api updates
levels: # target soc levels for UI
- 30
- 50
- 80
- 100
onDisconnect: # set defaults when vehicle disconnects
mode: pv # switch back to pv mode
targetSoC: 100 # charge to 100%

View file

@ -122,12 +122,6 @@
"target": {
"type": "integer"
},
"levels": {
"type": "array",
"items": {
"type": "integer"
}
},
"estimate": {
"type": "boolean"
}
@ -175,4 +169,4 @@
]
}
}
}
}

View file

@ -292,7 +292,7 @@ func TargetChargeHandler(loadpoint core.LoadPointAPI) http.HandlerFunc {
timeS, ok := vars["time"]
timeV, err := time.ParseInLocation("2006-01-02T15:04:05", timeS, timezone())
if !ok || err != nil || timeV.Before(time.Now()) {
if !ok || err != nil {
log.DEBUG.Printf("parse time: %v", err)
w.WriteHeader(http.StatusBadRequest)
return